13. Enviando emails

En esta sección del libro vamos a introducir algo de interactividad en nuestra API y adaptar nuestro manejador de registro de usuarios para que envíe al usuario un correo electrónico de bienvenida después de que se registren con éxito.

En el proceso de hacer esto, vamos a abordar algunos temas interesantes. Aprenderás:

  • Cómo utilizar el servicio SMTP de Mailtrap para enviar y monitorear correos electrónicos de prueba durante el desarrollo.
  • Cómo utilizar el paquete html/template y la funcionalidad de archivos integrados de Go para crear plantillas dinámicas y fáciles de gestionar para el contenido de tus correos electrónicos.

  • Cómo crear un paquete internal/mailer reutilizable para enviar correos electrónicos desde tu aplicación.

  • Cómo implementar un patrón para enviar correos electrónicos en gorutinas en segundo plano y cómo esperar a que se completen durante un apagado controlado.

13.1 Server SMTP: Configuracion

Para desarrollar nuestra funcionalidad de envío de correos electrónicos, necesitaremos acceso a un servidor SMTP (Protocolo de Transferencia de Correo Simple) que podamos utilizar de forma segura con fines de prueba.

Existen numerosos proveedores de servicios SMTP (como Postmark, Sendgrid o Amazon SES) que podríamos utilizar para enviar nuestros correos electrónicos, o incluso podríamos instalar y ejecutar nuestro propio servidor SMTP. Sin embargo, en este libro vamos a utilizar Mailtrap.

La razón para utilizar Mailtrap es porque es un servicio especializado para enviar correos electrónicos durante el desarrollo y las pruebas. Básicamente, entrega todos los correos electrónicos en una bandeja de entrada a la que puedes acceder, en lugar de enviarlos al destinatario real.

No tengo ninguna afiliación con la empresa, simplemente encuentro que el servicio funciona bien y es fácil de usar. También ofrecen un plan “gratis para siempre”, que debería ser suficiente para cualquiera que esté siguiendo este post y programando junto con él.

Nota: Si ya tienes tu propio servidor SMTP, o hay otro proveedor de SMTP alternativo que prefieres utilizar, está perfectamente bien. Siéntete libre de pasar al siguiente capítulo.

13.1 Configurando Mailtrap

Para configurar una cuenta en Mailtrap, dirígete a la página de registro donde puedes inscribirte utilizando tu dirección de correo electrónico o tus cuentas de Google o GitHub (si las tienes).

Una vez que te hayas registrado e iniciado sesión, utiliza el menú para navegar a Testing › Inboxes de entrada. Deberías ver una página que enumera tus bandejas de entrada disponibles.

Cada cuenta de Mailtrap viene con una bandeja de entrada gratuita, que por defecto se llama “Demo inbox”. Puedes cambiar el nombre si lo deseas haciendo clic en el icono de lápiz bajo “Acciones”. Si decides acceder a esa bandeja de entrada, deberías ver que actualmente está vacía y no contiene correos electrónicos.

Cada bandeja de entrada tiene su propio conjunto de credenciales SMTP, que puedes mostrar haciendo clic en el enlace “Mostrar credenciales” (resaltado por el recuadro rojo en la captura de pantalla anterior). Esto se expandirá para mostrar las credenciales SMTP de la bandeja de entrada.

Básicamente, cualquier correo electrónico que envíes utilizando estas credenciales SMTP terminará en esta bandeja de entrada en lugar de ser enviado al destinatario real. Si estás siguiendo los pasos, toma nota de las credenciales que se muestran en tu pantalla (o simplemente deja abierta la pestaña del navegador) porque las necesitarás en el próximo capítulo.

Nota: Las credenciales en la captura de pantalla anterior se han restablecido y ya no son válidas, ¡así que por favor no intentes usarlas!

13.2 Creando los templates del email

Para comenzar, mantendremos el contenido del correo electrónico de bienvenida muy simple, con un mensaje breve para informar al usuario que su registro fue exitoso y confirmación de su número de identificación. Similar a esto:

Hi,
Thanks for signing up for a Greenlight account. We're excited to have you on board!
For future reference, your user ID number is 123.
Thanks,
The Greenlight Team

Nota: Incluir el ID de usuario probablemente no sea algo que normalmente harías en un correo electrónico de bienvenida, pero es una forma sencilla de demostrar cómo incluir datos dinámicos en correos electrónicos, en lugar de contenido estático.

Hay varias aproximaciones diferentes que podríamos tomar para definir y gestionar el contenido de este correo electrónico, pero una forma conveniente y flexible es utilizar la funcionalidad de plantillas de Go del paquete html/template.

Si estás siguiendo los pasos, comienza creando una nueva carpeta internal/mailer/templates en tu directorio de proyecto y luego agrega un archivo user_welcome.tmpl. Así:

$ mkdir -p internal/mailer/templates
$ touch internal/mailer/templates/user_welcome.tmpl

Dentro de este archivo, vamos a definir tres plantillas con nombres para usar como parte de nuestro correo electrónico de bienvenida:

  • Una plantilla “subject” que contiene la línea de asunto del correo electrónico.
  • Una plantilla “plainBody” que contiene la variante de texto sin formato del cuerpo del mensaje del correo electrónico.
  • Una plantilla “htmlBody” que contiene la variante HTML del cuerpo del mensaje del correo electrónico.

Adelante, actualiza el archivo internal/mailer/templates/user_welcome.tmpl para incluir el contenido de estas plantillas:

{{define "subject"}}Welcome to Greenlight!{{end}}

{{define "plainBody"}}
Hi,
Thanks for signing up for a Greenlight account. We're excited to have you on board!
For future reference, your user ID number is {{.ID}}.
Thanks,
The Greenlight Team
{{end}}

{{define "htmlBody"}}
<!doctype html>
<html>
<head>
  <meta name="viewport" content="width=device-width" />
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
  <p>Hi,</p>
  <p>Thanks for signing up for a Greenlight account. We're excited to have you on board!</p>
  <p>For future reference, your user ID number is {{.ID}}.</p>
  <p>Thanks,</p>
  <p>The Greenlight Team</p>
</body>
</html>
{{end}}

Si has leído “Let’s Go”, esta sintaxis y estructura de plantillas deberían resultarte muy familiares, y no nos detendremos en los detalles aquí. Pero básicamente:

  • Hemos definido las tres plantillas con nombres utilizando las etiquetas {{define "..."}}...{{end}}.
  • Puedes renderizar datos dinámicos en estas plantillas a través del carácter . (llamado punto).

En el próximo capítulo pasaremos una estructura User a las plantillas como datos dinámicos, lo que significa que luego podremos renderizar el ID del usuario utilizando la etiqueta {{.ID}} en las plantillas.

Nota: Si necesitas cambiar con frecuencia el texto de los correos electrónicos o requieres que los usuarios puedan editarlo, podría ser apropiado almacenar estas plantillas como cadenas en tu base de datos. Sin embargo, he encontrado que almacenarlas en un archivo, como lo estamos haciendo aquí, es un enfoque menos complicado y un buen punto de partida para la mayoría de los proyectos.

13.3 Enviando el email de bienvenida

Ahora que tenemos el contenido del correo electrónico de bienvenida escrito, creemos el código para enviarlo.

Para enviar correos electrónicos, podríamos utilizar el paquete net/smtp de la biblioteca estándar de Go. Sin embargo, desafortunadamente, ha estado congelado durante algunos años y no admite algunas de las funciones que podrías necesitar en casos de uso más avanzados, como la capacidad de agregar archivos adjuntos.

En su lugar, recomiendo usar el paquete de terceros go-mail/mail para ayudar a enviar correos electrónicos. Está bien probado, tiene una buena documentación y una API muy clara y utilizable.

Si estás programando, utiliza go get para descargar la última versión v2.N.N de este paquete:

$ go get github.com/go-mail/mail/v2@v2
go: downloading github.com/go-mail/mail/v2 v2.3.0
go: downloading gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc
go get: added github.com/go-mail/mail/v2 v2.3.0
go get: added gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc

13.3 Creando un helper email

En lugar de escribir todo el código para enviar el correo electrónico de bienvenida en nuestro registerUserHandler, en este capítulo vamos a crear un nuevo paquete internal/mailer que envuelve la lógica para analizar nuestras plantillas de correo electrónico y enviar correos.

Además, también vamos a utilizar la funcionalidad de archivos embebidos de Go, de modo que los archivos de plantillas de correo electrónico se incluirán en nuestro binario cuando lo creemos más adelante. Esto es realmente útil porque significa que no tendremos que implementar estos archivos de plantillas por separado en nuestro servidor de producción.

Probablemente sea más fácil demostrar cómo funciona esto adentrándonos directamente en el código y hablando sobre los detalles a medida que avanzamos.

Comencemos creando un nuevo archivo internal/mailer/mailer.go:

$ touch internal/mailer/mailer.go

Agregamos el siguente codigo

package mailer

import (
	"bytes"
	"embed"
	"github.com/go-mail/mail/v2"
	"html/template"
	"time"
)

// Below we declare a new variable with the type embed.FS (embedded file system) to hold
// our email templates. This has a comment directive in the format `//go:embed <path>`
// IMMEDIATELY ABOVE it, which indicates to Go that we want to store the contents of the
// ./templates directory in the templateFS embedded file system variable.
// ↓↓↓
//
//go:embed "templates"
var templateFS embed.FS

// Define a Mailer struct which contains a mail.Dialer instance (used to connect to a
// SMTP server) and the sender information for your emails (the name and address you
// want the email to be from, such as "Alice Smith <alice@example.com>").
type Mailer struct {
	dialer *mail.Dialer
	sender string
}

func New(host string, port int, username, password, sender string) Mailer {
	// Initialize a new mail.Dialer instance with the given SMTP server settings. We
	// also configure this to use a 5-second timeout whenever we send an email.
	dialer := mail.NewDialer(host, port, username, password)
	dialer.Timeout = 5 * time.Second
	// Return a Mailer instance containing the dialer and sender information.
	return Mailer{
		dialer: dialer,
		sender: sender,
	}
}

// Define a Send() method on the Mailer type. This takes the recipient email address
// as the first parameter, the name of the file containing the templates, and any
// dynamic data for the templates as an any parameter.
func (m Mailer) Send(recipient, templateFile string, data any) error {
	// Use the ParseFS() method to parse the required template file from the embedded
	// file system.
	tmpl, err := template.New("email").ParseFS(templateFS, "templates/"+templateFile)
	if err != nil {
		return err
	}
	// Execute the named template "subject", passing in the dynamic data and storing the
	// result in a bytes.Buffer variable.
	subject := new(bytes.Buffer)
	err = tmpl.ExecuteTemplate(subject, "subject", data)
	if err != nil {
		return err
	}

	// Follow the same pattern to execute the "plainBody" template and store the result
	// in the plainBody variable.
	plainBody := new(bytes.Buffer)
	err = tmpl.ExecuteTemplate(plainBody, "plainBody", data)
	if err != nil {
		return err
	}
	// And likewise with the "htmlBody" template.
	htmlBody := new(bytes.Buffer)
	err = tmpl.ExecuteTemplate(htmlBody, "htmlBody", data)
	if err != nil {
		return err
	}
	// Use the mail.NewMessage() function to initialize a new mail.Message instance.
	// Then we use the SetHeader() method to set the email recipient, sender and subject
	// headers, the SetBody() method to set the plain-text body, and the AddAlternative()
	// method to set the HTML body. It's important to note that AddAlternative() should
	// always be called *after* SetBody().
	msg := mail.NewMessage()
	msg.SetHeader("To", recipient)
	msg.SetHeader("From", m.sender)
	msg.SetHeader("Subject", subject.String())
	msg.SetBody("text/plain", plainBody.String())
	msg.AddAlternative("text/html", htmlBody.String())
	// Call the DialAndSend() method on the dialer, passing in the message to send. This
	// opens a connection to the SMTP server, sends the message, then closes the
	// connection. If there is a timeout, it will return a "dial tcp: i/o timeout"
	// error.
	err = m.dialer.DialAndSend(msg)
	if err != nil {
		return err
	}
	return nil
}

13.2 Usando archivos de sistemas envevidos

Antes de continuar, tomémonos un momento para hablar más detalladamente sobre los sistemas de archivos embebidos, ya que hay algunas cosas que pueden resultar confusas cuando te encuentras con ellas por primera vez.

  1. Solo puedes utilizar la directiva //go:embed en variables globales a nivel de paquete, no dentro de funciones o métodos. Si intentas usarla en una función o método, obtendrás el error “go:embed no se puede aplicar a var dentro de func” durante la compilación.

  2. Cuando usas la directiva //go:embed "<ruta>" para crear un sistema de archivos embebido, la ruta debe ser relativa al archivo de código fuente que contiene la directiva. Entonces, en nuestro caso, //go:embed "templates" incrusta el contenido del directorio en internal/mailer/templates.

  3. El sistema de archivos embebido tiene su raíz en el directorio que contiene la directiva //go:embed. Entonces, en nuestro caso, para obtener el archivo user_welcome.tmpl, debemos recuperarlo desde templates/user_welcome.tmpl en el sistema de archivos embebido.

  4. Las rutas no pueden contener elementos . o .., ni pueden comenzar ni terminar con un /. Esto básicamente te limita a incrustar archivos que se encuentren en el mismo directorio (o un subdirectorio) que el código fuente que tiene la directiva //go:embed.

  5. Si la ruta es para un directorio, entonces todos los archivos en el directorio se incrustan de forma recursiva, excepto los archivos cuyos nombres comienzan con . o _. Si deseas incluir estos archivos, debes usar el carácter comodín * en la ruta, como //go:embed "templates/*".

  6. Puedes especificar varios directorios y archivos en una sola directiva. Por ejemplo:

    //go:embed "images" "styles/css" "favicon.ico" .
    
  7. El separador de ruta siempre debe ser una barra inclinada hacia adelante, incluso en máquinas con Windows.

13.3 Usando el helper email

Ahora que nuestra paquete de ayuda para correos electrónicos está en su lugar, necesitamos conectarlo con el resto de nuestro código en el archivo cmd/api/main.go. Específicamente, debemos hacer dos cosas:

  1. Adaptar nuestro código para aceptar la configuración del servidor SMTP como banderas de línea de comandos.
  2. Inicializar una nueva instancia de Mailer y ponerla a disposición de nuestros controladores a través de la estructura de la aplicación.

Si estás siguiendo estos pasos, asegúrate de utilizar tus propias configuraciones del servidor SMTP de Mailtrap de la sección anterior como valores predeterminados para las banderas de la línea de comandos aquí, no los valores exactos que estoy usando en el código a continuación.

package main

import (
	"context"
	"database/sql"
	"flag"
	"os"
	"time"

	"log/slog"

	_ "github.com/lib/pq"
	"github.com/nahuelev23/greenlight/internal/data"
	"github.com/nahuelev23/greenlight/internal/mailer"
)

const version = "1.0.0"

// Add maxOpenConns, maxIdleConns and maxIdleTime fields to hold the configuration
// settings for the connection pool.
type config struct {
	port int
	env  string
	db   struct {
		dsn          string
		maxOpenConns int
		maxIdleConns int
		maxIdleTime  time.Duration
	}
	// Add a new limiter struct containing fields for the requests-per-second and burst
	// values, and a boolean field which we can use to enable/disable rate limiting
	// altogether.
	limiter struct {
		rps     float64
		burst   int
		enabled bool
	}

	smtp struct {
		host     string
		port     int
		username string
		password string
		sender   string
	}
}

// Add a models field to hold our new Models struct.
type application struct {
	config config
	logger *slog.Logger
	models data.Models
	mailer mailer.Mailer
}

func main() {
	var cfg config

	flag.IntVar(&cfg.port, "port", 4000, "API server port")
	flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")

	flag.StringVar(&cfg.db.dsn, "db-dsn", "postgres://greenlight:pa55word@localhost/greenlight", "PostgreSQL DSN")

	flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
	flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
	flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time")

	// Create command line flags to read the setting values into the config struct.
	// Notice that we use true as the default for the 'enabled' setting?
	flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second")
	flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
	flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")

	// Read the SMTP server configuration settings into the config struct, using the
	// Mailtrap settings as the default values. IMPORTANT: If you're following along,
	// make sure to replace the default values for smtp-username and smtp-password
	// with your own Mailtrap credentials.
	flag.StringVar(&cfg.smtp.host, "smtp-host", "sandbox.smtp.mailtrap.io", "SMTP host")
	flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP port")
	flag.StringVar(&cfg.smtp.username, "smtp-username", "0653fe74fceaff", "SMTP username")
	flag.StringVar(&cfg.smtp.password, "smtp-password", "fff5987f793dfa", "SMTP password")
	flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight <no-reply@greenlight.nahueldev23.net>", "SMTP sender")
	flag.Parse()

	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

	db, err := openDB(cfg)
	if err != nil {
		logger.Error(err.Error())
		os.Exit(1)
	}

	defer db.Close()

	// Use the data.NewModels() function to initialize a Models struct, passing in the
	// connection pool as a parameter.
	app := application{
		config: cfg,
		logger: logger,
		models: data.NewModels(db),
    mailer: mailer.New(cfg.smtp.host, cfg.smtp.port, cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender),
	}

	// Call app.serve() to start the server.
	err = app.serve()

	if err != nil {
		logger.Error(err.Error())
		os.Exit(1)
	}
}

// The openDB() function returns a sql.DB connection pool.
func openDB(cfg config) (*sql.DB, error) {

	db, err := sql.Open("postgres", cfg.db.dsn)
	if err != nil {
		return nil, err
	}

	// Set the maximum number of open (in-use + idle) connections in the pool. Note that
	// passing a value less than or equal to 0 will mean there is no limit.
	db.SetMaxOpenConns(cfg.db.maxOpenConns)
	// Set the maximum number of idle connections in the pool. Again, passing a value
	// less than or equal to 0 will mean there is no limit.
	db.SetMaxIdleConns(cfg.db.maxIdleConns)
	// Set the maximum idle timeout for connections in the pool. Passing a duration less
	// than or equal to 0 will mean that connections are not closed due to their idle time.
	db.SetConnMaxIdleTime(cfg.db.maxIdleTime)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	err = db.PingContext(ctx)
	if err != nil {
		return nil, err
	}
	// Return the sql.DB connection pool.
	return db, nil
}

Y luego, la última cosa que debemos hacer es actualizar nuestro registerUserHandler para enviar realmente el correo electrónico, lo cual podemos hacer así:

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
  ... // Nothing above here needs to change.
	// Call the Send() method on our Mailer, passing in the user's email address,
	// name of the template file, and the User struct containing the new user's data.
	err = app.mailer.Send(user.Email, "user_welcome.tmpl", user)
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}

	err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

¡Bien, intentemos esto! Ejecuta la aplicación y luego, en otra terminal, utiliza curl para registrar un nuevo usuario con la dirección de correo electrónico bob@example.com:

$ BODY='{"name": "Bob Jones", "email": "bob@example.com", "password": "pa55word"}'
$ curl -w '\nTime: %{time_total}\n' -d "$BODY" localhost:4000/v1/users
{
  "user": {
    "id": 3,
    "created_at": "2021-04-11T20:26:22+02:00",
    "name": "Bob Jones",
    "email": "bob@example.com",
    "activated": false
  }
}
Time: 2.331957

Si todo está configurado correctamente, deberías recibir una respuesta 201 Created que contenga los detalles del nuevo usuario, similar a la respuesta anterior.

Importante: Si recibes un error “dial tcp: connect: conexión rechazada”, esto significa que tu aplicación no pudo conectarse al servidor SMTP de Mailtrap. Por favor, verifica que tus credenciales de Mailtrap son correctas o intenta usar el puerto 2525 en lugar del puerto 25.

Nota: He vuelto a utilizar la bandera -w para mostrar el tiempo total que tomó para completar la solicitud, que en mi caso fue aproximadamente 2.3 segundos. Ese es un tiempo bastante largo para que una solicitud HTTP se complete, y lo aceleraremos en el próximo capítulo al enviar el correo electrónico de bienvenida en un goroutine en segundo plano.

13.3 Revisando el email en Mailtrap

Si has estado siguiendo los pasos y estás utilizando las credenciales del servidor SMTP de Mailtrap, cuando regreses a tu cuenta, deberías ver ahora el correo electrónico de bienvenida para bob@example.com en tu bandeja de entrada de Demo.

Si lo deseas, también puedes hacer clic en la pestaña Text para ver la versión en texto sin formato del correo electrónico, y en la pestaña Raw para ver el correo electrónico completo, incluidas las cabeceras.

En resumen, cubrimos bastante terreno en este capítulo. Pero lo bueno del patrón que hemos construido es que es fácil de ampliar. Si queremos enviar otros correos electrónicos desde nuestra aplicación en el futuro, simplemente podemos crear un archivo adicional en nuestra carpeta internal/mailer/templates con el contenido del correo electrónico y luego enviarlo desde nuestros controladores de la misma manera que lo hemos hecho aquí.

13.3 Informacion adicional

13.3 Reintentos de envio de correo electrónico

Si lo deseas, puedes hacer que el proceso de envío de correos electrónicos sea un poco más robusto agregando alguna funcionalidad básica de ‘reintentar’ al método Mailer.Send(). Por ejemplo:

func (m Mailer) Send(recipient, templateFile string, data any) error {
    // ...

    // Intenta enviar el correo electrónico hasta tres veces antes de abortar y devolver el error final.
    // Dormimos durante 500 milisegundos entre cada intento.
    for i := 1; i <= 3; i++ {
        err = m.dialer.DialAndSend(msg)
        // Si todo funcionó, devuelve nil.
        if nil == err {
            return nil
        }
        // Si no funcionó, espera un corto período y vuelve a intentarlo.
        time.Sleep(500 * time.Millisecond)
    }
    return err
}

Pista: En el código anterior, estamos utilizando la cláusula if nil == err para verificar si el envío fue exitoso, en lugar de if err == nil. Son funcionalmente equivalentes, pero tener nil como el primer elemento en la cláusula hace que sea un poco visualmente llamativo y menos propenso a confundirse con la cláusula mucho más común if err != nil.

Esta funcionalidad de reintento es una adición relativamente simple a nuestro código, pero ayuda a aumentar la probabilidad de que los correos electrónicos se envíen correctamente en caso de problemas de red transitorios. Si estás enviando correos electrónicos en un proceso en segundo plano (como haremos en el próximo capítulo), es posible que desees hacer la duración del tiempo de espera aún más larga aquí, ya que no afectará sustancialmente al cliente y brindará más tiempo para resolver problemas transitorios.

13.4 Enviando emails en background

Como mencionamos brevemente en el último capítulo, enviar el correo de bienvenida desde el método registerUserHandler agrega bastante latencia al tiempo total de ida y vuelta de solicitud/respuesta para el cliente. Una forma de reducir esta latencia sería enviar el correo electrónico en un goroutine de fondo. Esto efectivamente ‘desacoplaría’ la tarea de enviar un correo electrónico del resto del código en nuestro registerUserHandler, lo que significa que podríamos devolver una respuesta HTTP al cliente sin esperar a que se complete el envío del correo electrónico.

En su forma más simple, podríamos adaptar nuestro controlador para ejecutar el envío de correo en un goroutine en segundo plano de la siguiente manera:

package main

// ...

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
    // ...

    // Launch a goroutine which runs an anonymous function that sends the welcome email.
    go func() {
        err := app.mailer.Send(user.Email, "user_welcome.tmpl", user)
        if err != nil {
            // Importantly, if there is an error sending the email then we use the
            // app.logger.Error() helper to manage it, instead of the
            // app.serverErrorResponse() helper like before.
            app.logger.Error(err.Error())
        }
    }()

    // Note that we also change this to send the client a 202 Accepted status code.
    // This status code indicates that the request has been accepted for processing, but
    // the processing has not been completed.
    err := app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

Cuando este código se ejecuta ahora, se lanzará un nuevo goroutine ‘background’ para enviar el correo de bienvenida. El código en este goroutine de fondo se ejecutará concurrentemente con el código posterior en nuestro registerUserHandler, lo que significa que ya no estamos esperando a que se envíe el correo antes de devolver una respuesta JSON al cliente. Es muy probable que el goroutine de fondo siga ejecutando su código mucho después de que registerUserHandler haya retornado.

  • Utilizamos el método app.logger.Error() para gestionar cualquier error en nuestro goroutine de fondo. Esto se debe a que, para cuando encontramos los errores, es probable que el cliente ya haya recibido una respuesta 202 Accepted por nuestro ayudante writeJSON(). Es importante destacar que no queremos utilizar el ayudante app.serverErrorResponse() para manejar errores en nuestro goroutine de fondo, ya que esto resultaría en intentar escribir una segunda respuesta HTTP y obtendríamos un error “http: superfluous response.WriteHeader call” de nuestro http.Server en tiempo de ejecución.
  • El código que se ejecuta en el goroutine de fondo forma un cierre sobre las variables user y app. Es importante tener en cuenta que estas variables ‘cerradas’ no tienen un ámbito exclusivo para el goroutine de fondo, lo que significa que cualquier cambio que realices en ellas se reflejará en el resto de tu código. Para un ejemplo sencillo de esto, consulta el siguiente código de ejemplo. En nuestro caso, no estamos cambiando el valor de estas variables de ninguna manera, por lo que este comportamiento no nos causará problemas. Pero es importante tenerlo en cuenta.

Vale, probemos esto.

Reinicia la API y luego procede a registrar otro usuario nuevo con la dirección de correo electrónico carol@example.com. Hazlo de la siguiente manera:

$ BODY='{"name": "Carol Smith", "email": "carol@example.com", "password": "pa55word"}'
$ curl -w '\nTime: %{time_total}\n' -d "$BODY" localhost:4000/v1/users
  {
  "user": {
  "id": 4,
  "created_at": "2021-04-11T21:21:12+02:00",
  "name": "Carol Smith",
  "email": "carol@example.com",
  "activated": false
  }
}
Time: 0.268639

En esta ocasión, deberías notar que el tiempo que tarda en devolver la respuesta es mucho más rápido; en mi caso, 0.27 segundos en comparación con los anteriores 2.33 segundos. Además, si revisas tu bandeja de entrada en Mailtrap, deberías ver que el correo electrónico para carol@example.com se ha entregado correctamente.

13.4 Recuperando de un panic

Es importante tener en cuenta que cualquier pánico que ocurra en este goroutine de fondo no se recuperará automáticamente mediante nuestro middleware recoverPanic() o el servidor HTTP de Go (http.Server), y provocará la terminación de toda nuestra aplicación.

En goroutines de fondo muy simples (como las que hemos estado utilizando hasta ahora), esto es menos preocupante. Sin embargo, el código involucrado en el envío de un correo electrónico es bastante complejo (incluyendo llamadas a un paquete de terceros) y el riesgo de un pánico en tiempo de ejecución no es despreciable. Por lo tanto, necesitamos asegurarnos de que cualquier pánico en este goroutine de fondo se recupere manualmente, utilizando un patrón similar al de nuestro middleware recoverPanic(). Lo demostraré. Reabre tu archivo cmd/api/users.go y actualiza el registerUserHandler de la siguiente manera:

package main

import (
	"errors"
	"fmt"
	"net/http"
	"greenlight.alexedwards.net/internal/data"
	"greenlight.alexedwards.net/internal/validator"
)

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
	// ...

	// Launch a background goroutine to send the welcome email.
	go func() {
		// Run a deferred function which uses recover() to catch any panic and log an
		// error message instead of terminating the application.
		defer func() {
			if err := recover(); err != nil {
				app.logger.Error(fmt.Sprintf("%v", err))
			}
		}()

		// Send the welcome email.
		err := app.mailer.Send(user.Email, "user_welcome.tmpl", user)
		if err != nil {
			app.logger.Error(err.Error())
		}
	}()

	err := app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

13.4 Usando una helper function

Si necesitas ejecutar muchas tareas en segundo plano en tu aplicación, puede resultar tedioso repetir el mismo código de recuperación de pánico una y otra vez, y existe el riesgo de que puedas olvidar incluirlo por completo.

Para ayudar con esto, es posible crear una función de ayuda sencilla que envuelva la lógica de recuperación de pánico. Si estás siguiendo los pasos, abre tu archivo cmd/api/helpers.go y crea un nuevo método de ayuda background() de la siguiente manera:

package main

// ...

// The background() helper accepts an arbitrary function as a parameter.
func (app *application) background(fn func()) {
	// Launch a background goroutine.
	go func() {
		// Recover any panic.
		defer func() {
			if err := recover(); err != nil {
				app.logger.Error(fmt.Sprintf("%v", err))
			}
		}()

		// Execute the arbitrary function that we passed as the parameter.
		fn()
	}()
}

En este caso, hemos configurado el ayudante background() para que acepte cualquier función con la firma func() como parámetro y la almacene en la variable fn. Luego, inicia un goroutine en segundo plano, utiliza una función diferida para recuperar cualquier pánico y registrar el error, y finalmente ejecuta la función llamando a fn().

Ahora que esto está en su lugar, actualicemos nuestro registerUserHandler para usarlo de la siguiente manera:

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
	// ...

	// Use the background helper to execute an anonymous function that sends the welcome
	// email.
	app.background(func() {
		err := app.mailer.Send(user.Email, "user_welcome.tmpl", user)
		if err != nil {
			app.logger.Error(err.Error())
		}
	})

	err := app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Verifiquemos que esto sigue funcionando. Reinicia la API y luego crea otro usuario nuevo con la dirección de correo electrónico dave@example.com:

$ BODY='{"name": "Dave Smith", "email": "dave@example.com", "password": "pa55word"}'
$ curl -w '\nTime: %{time_total}\n' -d "$BODY" localhost:4000/v1/users
{
  "user": {
    "id": 5,
    "created_at": "2021-04-11T21:33:07+02:00",
    "name": "Dave Smith",
    "email": "dave@example.com",
    "activated": false
  }
}
Time: 0.267692

Si todo está configurado correctamente, ahora deberías ver el correo electrónico correspondiente aparecer nuevamente en tu bandeja de entrada de Mailtrap.

13.5 Apagando de manera agraciada las tareas een background

El envío de nuestro correo electrónico de bienvenida en segundo plano funciona bien, pero aún hay un problema que debemos abordar. Cuando iniciamos un apagado elegante de nuestra aplicación, no esperará a que se completen las goroutines en segundo plano que hemos lanzado. Por lo tanto, si cerramos nuestro servidor en un momento desafortunado, es posible que se haya creado un nuevo cliente en nuestro sistema pero nunca se le enviará su correo electrónico de bienvenida.

Afortunadamente, podemos evitar esto utilizando la funcionalidad sync.WaitGroup de Go para coordinar el apagado elegante y nuestras goroutines en segundo plano.

13.5 Una intruduccion a sync.WaitGroup

Cuando deseas esperar a que una colección de goroutines termine su trabajo, la herramienta principal para ayudar con esto es el tipo sync.WaitGroup.

La forma en que funciona es conceptualmente un poco como un ‘contador’. Cada vez que lanzas una goroutine en segundo plano, puedes incrementar el contador en 1, y cuando cada goroutine finaliza, luego decrementas el contador en 1. Luego, puedes monitorear el contador, y cuando es igual a cero, sabes que todas tus goroutines en segundo plano han terminado.

Echemos un vistazo rápido a un ejemplo independiente de cómo sync.WaitGroup funciona en la práctica. En el siguiente código, lanzaremos cinco goroutines que imprimirán “hello from a goroutine” y utilizaremos sync.WaitGroup para esperar a que todas ellas se completen antes de que el programa finalice.

package main

import (
	"fmt"
	"sync"
)

func main() {
	// Declare a new WaitGroup.
	var wg sync.WaitGroup

	// Execute a loop 5 times.
	for i := 1; i <= 5; i++ {
		// Increment the WaitGroup counter by 1, BEFORE we launch the background routine.
		wg.Add(1)

		// Launch the background goroutine.
		go func() {
			// Defer a call to wg.Done() to indicate that the background goroutine has
			// completed when this function returns. Behind the scenes this decrements
			// the WaitGroup counter by 1 and is the same as writing wg.Add(-1).
			defer wg.Done()
			fmt.Println("hello from a goroutine")
		}()
	}

	// Wait() blocks until the WaitGroup counter is zero --- essentially blocking until all
	// goroutines have completed.
	wg.Wait()

	fmt.Println("all goroutines finished")
}

Si corres el codigo de arriba veras algo asi:

hello from a goroutine
hello from a goroutine
hello from a goroutine
hello from a goroutine
hello from a goroutine
all goroutines finishe

Algo importante que es necesario destacar aquí es que incrementamos el contador con wg.Add(1) inmediatamente antes de lanzar la goroutine en segundo plano. Si llamáramos a wg.Add(1) en la goroutine en segundo plano misma, existiría una condición de carrera porque wg.Wait() podría llamarse potencialmente antes de que el contador se haya incrementado.

13.5 Arreglando nuestra applicacion

Vamos a actualizar nuestra aplicación para incorporar un sync.WaitGroup que coordine nuestro apagado elegante y las goroutines en segundo plano. Comenzaremos en nuestro archivo cmd/api/main.go y editaremos la estructura de la aplicación para contener un nuevo sync.WaitGroup. Así:

package main

import (
	"context"
	"database/sql"
	"flag"
	"log/slog"
	"os"
	"sync"
	"time"
	"nahueldev23.github.com/internal/data"
	"nahueldev23.github.com/internal/mailer"
	_ "github.com/lib/pq"
)

// Include a sync.WaitGroup in the application struct. The zero-value for a
// sync.WaitGroup type is a valid, usable sync.WaitGroup with a 'counter' value of 0,
// so we don't need to do anything else to initialize it before we can use it.
type application struct {
	config config
	logger *slog.Logger
	models data.Models
	mailer mailer.Mailer
	wg     sync.WaitGroup
}
...

A continuación, dirígete al archivo cmd/api/helpers.go y actualiza el ayudante app.background() para que el contador de sync.WaitGroup se incremente cada vez antes de lanzar una goroutine en segundo plano, y luego se decremente cuando esta se complete. Sería algo así:

package main

//...

func (app *application) background(fn func()) {
	// Increment the WaitGroup counter.
	app.wg.Add(1)

	// Launch the background goroutine.
	go func() {
		// Use defer to decrement the WaitGroup counter before the goroutine returns.
		defer app.wg.Done()
		defer func() {
			if err := recover(); err != nil {
				app.logger.Error(fmt.Sprintf(&quot;%v&quot;, err))
			}
		}()
		fn()
	}()
}

Luego, lo último que debemos hacer es actualizar nuestra funcionalidad de apagado elegante para que utilice nuestro nuevo sync.WaitGroup y espere a que se completen todas las goroutines en segundo plano antes de cerrar la aplicación. Podemos hacer esto adaptando nuestro método app.serve() de la siguiente manera:

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"log/slog"
)

func (app *application) serve() error {
	srv := &http.Server{
		Addr:         fmt.Sprintf(":%d", app.config.port),
		Handler:      app.routes(),
		IdleTimeout:  time.Minute,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		ErrorLog: slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
	}

	shutdownError := make(chan error)

	go func() {
		quit := make(chan os.Signal, 1)
		signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
		s := <-quit
		app.logger.Info("shutting down server", "signal", s.String())

		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

		// Call Shutdown() on the server like before, but now we only send on the
		// shutdownError channel if it returns an error.
		err := srv.Shutdown(ctx)
		if err != nil {
			shutdownError <- err
		}

		// Log a message to say that we're waiting for any background goroutines to
		// complete their tasks.
		app.logger.Info("completing background tasks", "addr", srv.Addr)

		// Call Wait() to block until our WaitGroup counter is zero --- essentially
		// blocking until the background goroutines have finished. Then we return nil on
		// the shutdownError channel to indicate that the shutdown completed without
		// any issues.
		app.wg.Wait()
		shutdownError <- nil
	}()

	app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)
	err := srv.ListenAndServe()
	if !errors.Is(err, http.ErrServerClosed) {
		return err
	}

	err = <-shutdownError
	if err != nil {
		return err
	}

	app.logger.Info("stopped server", "addr", srv.Addr)
	return nil
}

Para probar esto, reinicia la API y luego envía una solicitud al endpoint POST /v1/users, seguido inmediatamente por una señal SIGTERM. Por ejemplo:

$ BODY='{"name": "Edith Smith", "email": "edith@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users & pkill -SIGTERM api &

Cuando lo hagas, los registros de tu servidor deberían parecerse a la salida a continuación:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:14.722+02:00 level=INFO msg="shutting down server" signal=terminated
time=2023-09-10T10:59:14.722+02:00 level=INFO msg="completing background tasks" addr=:4000
time=2023-09-10T10:59:18.722+02:00 level=INFO msg="stopped server" addr=:4000

Observa cómo se escribe el mensaje “completing background tasks”, luego hay una pausa de unos segundos mientras se completa el envío del correo electrónico en segundo plano, seguido finalmente por el mensaje “stopped server”.

Esto ilustra de manera efectiva cómo el proceso de apagado elegante esperó a que se enviara el correo electrónico de bienvenida (lo cual tomó alrededor de dos segundos en mi caso) antes de finalmente cerrar la aplicación.

Post Relacionados