¿Porque escoger Go para crear una API? 🤔
Go es un lenguaje fantástico creado por Google, fuertemente tipado, fácil de leer y con multi-hilo y asincronismo por defecto.
En este post vamos a crear una API CRUD de lista de Videojuegos (Porque el típico TO DO es muy aburrido 😘) con Go Fiber y GORM con una base de datos PostgreSQL, el proyecto se llamara gamelist.
Requisitos Previos 🛑
Como punto de partido tendremos un proyecto de Go usando Go Modules, tener una instancia de GORM con PostgreSQL y un servidor básico con Fiber. Si no sabes como, en posts anteriores cubro estos temas, combínalos y sigue el tutorial.
Tutorial Conectar PostgresSQL en Go con GORM
Marlos Rodriguez ・ Jan 30 '21 ・ 3 min read
Guía de Crear una API con Go Fiber de 0
Marlos Rodriguez ・ Jan 30 '21 ・ 4 min read
La Estructura del Proyecto
Si has seguido los tutoriales previos, tendras dentro de tu proyecto un archivo main.go
con un servidor de Fiber y una carpeta con el archivo /storage/connectDB.go
con la conexión a la base de datos. Creamos 3 carpetas mas:
- handlers: Aquí crearemos las funciones HTTP.
- models: Nuestros modelos para la bases de datos.
- utils: Y aquí nuestras funciones y herramientas para nuestro proyecto.
¿Que son las variables de Entorno? Y porque tienes que usarlas 🤗
Las Variables de Entorno (O ENV por "Environment variables") son variables de valor dinámico que afectan el funcionamiento de la App. En proyectos de programación es información importante que afecta su funcionamiento y que debería estar oculto al publicarlo (Por ejemplo al subirlo al GitHub), en estos archivos deben estar cosas como los datos de conexión a la base datos.
Para continuar con el proyecto vamos a usar ENV.
Usar ENV en nuestro proyecto 🤫
Para usar ENV agregamos en la ruta base de nuestro proyecto un archivo .env
, la sintaxis de los ENV es key=value
. Vamos a tomar los datos de nuestra Base de datos, y ponerla en nuestro ENV:
DB_HOST=localhost
DB_USER=postgres
DB_PORT=5432
DB_PASSWORD=password
DB_NAME=db
Los nombres de las variables suelen estar en mayúsculas y separados por "_".
Para acceder a nuestras variables utilizares el paquete os y su función Getenv os.Getenv(key)
esta función buscara la variable que le pasemos y si existe retornara el valor.
¡Alto! Importante Esto por si solo no nos servira a nosotros ¿Por que? Porque esto busca en las ENV por defecto de Go, solo con agregar el archivo .env
el programa no sabra que existe y no cargara nuestras variables.
Para que nuestra aplicación carga las variables y podamos usarlas, usaremos el paquete GoDotEnv que cargara nuestro archivo .env
permitiendo usarlas. lo agregamos con el comando go get
:
go get github.com/joho/godotenv
Para usarlo agregamos en el archivo donde usemos las ENV, en los import
el paquete GoDotEnv:
import (
//Autoload the env
_ "github.com/joho/godotenv/autoload"
)
Al importar el autoload
como su nombre indica solo al importarlo cargara nuestro archivo .env
. Otra forma de hacerlo, pero que puede darle problemas en el deploy, es:
import (
"github.com/joho/godotenv"
"log"
"os"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
val:= os.Getenv("key")
}
Implementar ENV y mejores practicas 🤓
Ahora con el paquete GoDotEnv, podemos usar ENV en todo el proyecto pero hay ciertas cosas en tener en cuenta.
- Orden de uso: Esto es algo importante, como dije podemos usar GoDotEnv en todo el proyecto, pero el orden es importante, deberías usar este paquete cargar el
.env
en una etapa temprana del programa, porque puede que intente cargar una variable importante y no lo encuentra. - Rendimiento: Acceder al ENV es un poco lento, ten esto en cuenta y no abuses de esto.
Para evitar esto, me gusta crear en mis proyecto la utilidad accessENV.go
el cual se encargara de acceder a todas nuestras variables y las guardara en una cache para que cuando se necesite otra vez podrá accederse rápidamente.
Creamos el archivo accessENV.go
dentro de la carpeta utils
, importamos los paquetes os
sync
y GoDotEnv
crearemos una función con el mismo nombre accessENV
y fuera de la función crearemos nuestra "cache" que sera un map
de Go con un RWMutex
del paquete sync
, este sera el resultado de la función:
package utils
import (
"os"
"sync"
//Autoload the env
_ "github.com/joho/godotenv/autoload"
)
var (
environment = map[string]string{}
environmentMutex = sync.RWMutex{}
)
//AccessENV Return the ENV if exits
func AccessENV(key string) string {
//Obtiene el valor del map, si existe regresa el valor
environmentMutex.RLock()
val := environment[key]
environmentMutex.RUnlock()
if environment[key] != "" {
return val
}
//Si el valor no existe, lo obtiene de ENV
val := os.Getenv(key)
if val == "" || len(val) <= 0 {
return ""
}
//Si existe en ENV, lo asigna al map
environmentMutex.Lock()
environment[key] = val
environmentMutex.Unlock()
return val
}
¿Que es el RWMutex
y porque no uso solo el map
?
RWMutex is a reader/writer mutual exclusion lock
Esto quiere decir que bloquea la lectura y escritura de los map
, el cual es necesario si se trabaja con gorutines, porque puede dar un fatal error
debido que si no se bloquea, dos gorutines pueden intentar leer o escribir al mismo tiempo, corrompiendo los datos y deteniendo la aplicación.
La forma es que como funciona es que:
-
Lock
Unlock
para bloquear y desbloquear la escritura -
RLock
RUnlock
para bloquear y desbloquear la lectura El uso de maps es seguro dentro de las funciones y no dará ningún error.
Ahora en podemos usar esto y acceder a nuestras variables de manera segura y rápida. Con nuestra función vamos a cambiar la función de ConnectDB()
para acceder a los datos de la base de datos desde el archivo .env
, cambiamos el codigo de esta manera:
package storage
import (
"fmt"
"log"
"strconv"
"github.com/jinzhu/gorm"
//Postgres Driver imported
_ "github.com/lib/pq"
"gamelist/utils"
)
func ConnectDB() *gorm.DB {
var (
host = utils.AccessENV("DB_HOST")
user = utils.AccessENV("DB_USER")
port = utils.AccessENV("DB_PORT")
password = utils.AccessENV("DB_PASSWORD")
name = utils.AccessENV("DB_NAME")
)
if host == "" {
log.Fatalln("Error loading ENV")
return nil
}
portInt, err := strconv.Atoi(port)
if err != nil {
log.Fatalln("Error en convertir el port : " + err.Error())
return nil
}
}
El resto del código es igual, ahora comprobamos si las variables están vacías, en cuyo caso ocurrió un error al obtener los valores de ENV. Luego se convierte el port de string
a int
, formato que la función necesita.
Crear el modelo de Juego
Para poder trabajar con GORM necesitas crear un modelo. Dentro de la carpeta models
creamos el archivo juego.go
, para este proyecto solo usaremos un modelo, el cual sera Juego
que tendra Nombre
, Desarrollador
y precio
, usaremos el Model
de GORM que nos provee con todo lo necesario para manipular la información:
package models
import (
"time"
"github.com/jinzhu/gorm"
)
//Juego estructura
type Juego struct {
gorm.Model
Nombre string `gorm:"not null" json:"nombre"`
Desarrollador string `gorm:"not null" json:"desarrollador"`
Precio int `gorm:"not null" json:"precio"`
}
El gorm.Model
nos agrega esto a nuestro Juego
:
// gorm.Model definition
type Model struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
Ahorrándonos de agregarlos nosotros, por ultimo agregamos un Hook
que nos permite GORM. Este se llamara AfterUpdate
que se ejecuta cada que actualizamos algo y la usaremos para actualizar el dato UpdatedAt
automáticamente, este es el código:
// Updating data in same transaction
func (u *User) AfterUpdate(tx *gorm.DB) (err error) {
u.UpdatedAt = time.Now()
return
}
Por ultimo para poder usarlo, tenemos que hacer la migración en la base de datos. Dentro del ConnectDB()
antes del return
agregamos DB.AutoMigrate(&models.User{})
esto hara la migración en la base de datos si no existe, y si existe simplemente la ignorara evitando cualquier tipo de problemas.
Ya cree el modelo ¿Ahora como lo uso? Creando el CRUD de la DB 👨💻
Esto constara de 5 funciones que interactuarán con nuestra base de datos y sera la base de nuestro CRUD. Dentro de la carpeta storage creamos juegos.go
donde crearemos nuestro CRUD.
importaremos estos paquetes:
import (
"log"
"time"
"github.com/jinzhu/gorm"
"gamelist/models"
)
Existe diferentes maneras de trabajar con la instancia de la base de datos, en este tutorial crearemos una estructura con la base de datos y agregaremos los métodos de nuestro CRUD.
//JuegoDB struct
type JuegoDB struct {
db *gorm.DB
}
esto creara una estructura con una instancia de GORM como parámetro, podremos crear tantas como queramos, importante que nombre de la instancia de GORM este en minuscula y asi no se exporte. Para poder usarlo tenemos que crear una nueva con una instancia de GORM conectada a nuestra base de datos, para ellos crearemos una función que la cree:
//NuevoJuegoDB Create a new storage user service
func NuevoJuegoDB () JuegoDB {
nuevaDB := ConnectDB()
nuevoServicio := JuegoDB{db: nuevaDB}
return nuevoServicio
}
Esto creara una nueva instancia y sera lo que usemos después en nuestros handlers. Como estamos en la misma carpeta que la función ConnectDB()
podremos usarla sin necesidad de ninguna importación.
Crear los métodos del CRUD
Ahora que podemos crear una nueva instancia JuegoDB
necesitamos agregar los métodos a nuestra estructura, esto hara que al crear una nueva instancia en NuevoJuegoDB()
venga con funciones que podremos usar, para agregarlo como metodo tenemos que crear una función normal pero con (j *JuegoDB)
antes del nombre, así que vamos a hacerlo.
- Obtener un juego:
//ObtenerJuego del juego con el ID
func (j *JuegoDB) ObtenerJuego(id int) *models.Juego, error {
var juego *models.Juego = new(models.Juego)
// SELECT * FROM juegos WHERE id = 10;
if err := j.db.First(&juego, id).Error; err != nil {
return nil, err
}
return juego, nil
}
Esta función da por hecho que el id
existe y no esta vacío, comprobaremos el id en los handlers. Esta función crea una variable vacia, que se usara en la DB, GORM buscara automaticamente en función del modelo de la variable y le pasaremos el id
como parametro. En caso de error regresara un nil
y el error, si todo salio bien, regresara el juego
y un error nil
.
- Obtener todos los Juegos:
//ObtenerJuegos Obtener todos los Juegos
func (j *JuegoDB) ObtenerJuegos () []*models.Juego, error {
var juego []*models.Juego = []*models.Juego{new(models.Juego)
// SELECT * FROM juegos;
if err := j.db.Find(&juego).Error; err != nil {
return nil, err
}
return juego, nil
}
- Crear un Juego:
//CrearJuego crea un Juego
func (j *JuegoDB) CrearJuego(nuevoJuego *models.Juego) *models.Juego, bool, error {
if err := j.db.Create(&nuevoJuego).Error; err != nil {
return nil, false, err
}
return *nuevoJuego, true, nil
}
- Modificar un Juego:
//ModificarJuego Modifica el Juego
func (j *JuegoDB) ModificarJuego(nuevoJuego *models.Juego) bool, error {
if err := j.db.Model(&juego).Updates(&nuevoJuego).Error; err != nil {
return false, err
}
return true, nil
}
En esta función se utiliza Updates
que actualiza todos los campos que no estén vacíos. GORM, encadena las funciones, al agregar Model()
le decimos donde buscar, luego la función Updates
buscara con el id
del objeto y actualizara en la base de datos.
- Eliminar un Juego:
//EliminarJuego Elimina un Juego
func (j *JuegoDB) EliminarJuego(id int) bool, error {
if err := j.db.Delete(&nuevoJuego, id).Error; err != nil {
return false, err
}
return true, nil
}
Estas son todas las funciones de nuestro CRUD, Podremos acceder a los juegos, crearlo, modificarlo y eliminarlo.
Ahora con las funciones de la DB, Los Handlers 😎
Como ya dije, usaremos Go Fiber para nuestros Handlers y administrar las peticiones HTTP. Usaremos la misma estructura que las funciones de la Base de datos. Crearemos una estructura y le agregaremos las funciones de nuestro handler.
import (
"strconv"
"strings"
"time"
"github.com/go-redis/redis/v8"
"github.com/gofiber/fiber/v2"
"github.com/jinzhu/gorm"
"gamelist/storage"
"gamelist/models"
)
type juegosHandler struct {
BaseDatos storage.JuegoDB
}
Y crearemos la función para crear un nuevo handler:
//NuevoJuegosHandler Crear un nuevo handler
func NuevoJuegosHandler() *juegosHandler {
//Regresa nuevo Handler
return &juegosHandler{
BaseDatos: storage.NuevoJuegoDB(),
}
}
Ahora crearemos nuestros handlers usando Go Fiber, la forma de un handler de Fiber es: func Nombre(c *fiber.Ctx) error {...}
, son todas las mismas, toma como metodo el Context
o contexto de Fiber y regresa un error
si todo sale bien sera nil
. Ahora vamos a crear nuestras funciones:
- Obtener un Juego por ID:
//ObtenerJuego Obtener un Juego por ID
func (j *juegosHandler) ObtenerJuego(c *fiber.Ctx) error {
//Obtener el ID desde los parametros, es uno de los metodos de Fiber
ID := c.Params("id")
//Si el ID esta vacio, no se envio asi que regresa un error
if len(ID) < 0 {
//Esta es la forma de regresar errores en fiber
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Review your input"})
}
...
}
Para enviar errores en Fiber tienes que enviar un numero de Status
o Estatus, que indique que ha pasado con la petición, Fiber viene con codigos por defecto que son los estandares en la Web. Y opcionalmente puedes mandar información en formato JSON con la información del servidor, en este caso el error. Ahora convertiremos el ID, que es un string
a un int
para ser usado en la Base de datos:
//ObtenerJuego Obtener un Juego por ID
func (j *juegosHandler) ObtenerJuego(c *fiber.Ctx) error {
...
//Convertir ID a int
IntID, err := strconv.Atoi(ID)
if err != nil {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error converting in Integer", "data": err.Error()})
}
//Obtener datos del Juego
juego, err := j.BaseDatos.ObtenerJuego(ID)
if err != nil {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
}
//Regresar los datos del juego, Go lo convertira en JSON
return c.Status(fiber.StatusAccepted).JSON(juego)
}
- Obtener todos los juegos: Esta es mas simple que el anterior, no tenemos que hacer ninguna comprobación por parte del usuario, solo asegurarnos de obtener la lista de Juegos.
//ObtenerTodosJuegos Obtener Todos los Juegos
func (j *juegosHandler) ObtenerTodosJuegos(c *fiber.Ctx) error {
//Obtener datos del Juego
juego, err := j.BaseDatos.ObtenerJuego(ID)
if err != nil {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
}
//Regresar los datos del juego, Go lo convertira en JSON
return c.Status(fiber.StatusAccepted).JSON(juego)
}
Al igual que con el anterior, Go lo convertira en JSON automáticamente, no importa si es un array.
- Crear un Juego:
//CrearJuego Crea un nuevo Juego
func (j *juegosHandler) CrearJuego(c *fiber.Ctx) error {
//Crear el nuevo juego
var nuevoJuego *models.Juego
//Obtener los datos del body
if err := c.BodyParser(&nuevoJuego); err != nil {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Revisa tu Body", "data": err.Error()})
}
//Comprobar los datos del juego
//Nombre
if len(strings.TrimSpace(nuevoJuego.Nombre)) <= 0 || strings.TrimSpace(nuevoJuego.Nombre) == "" {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "El Nombre del juego es obligatorio")
}
//Desarrollador
if len(strings.TrimSpace(nuevoJuego.Desarrollador)) <= 0 || strings.TrimSpace(nuevoJuego.Desarrollador) == "" {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "El Desarrolladordel juego es obligatorio")
}
//Precio
if len(nuevoJuego.Precio) < 0 {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "El precio no puede ser negativo")
}
//Crear el Juego
juego, exitoso, err := j.BaseDatos.CrearJuego(nuevoJuego)
if err != nil || !exitoso {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
}
//Regresar los datos del juego, Go lo convertira en JSON
return c.Status(fiber.StatusAccepted).JSON(juego)
}
En esta función obtendremos los datos del nuevo juego del body
con los datos que el usuario deberia enviar, tendra que enviar un JSON
como este:
{
"nobmre": "Dark Souls",
"desarrollador": "From Software",
"precio": 20
}
- Modificar un Juego: Esta sera muy similar al anterior pero verificaremos los valores y si no lo envia lo tendra vacio y asi GORM lo ignorara:
//ModificarJuego Modificia un Juego
func (j *juegosHandler) ModificarJuego (c *fiber.Ctx) error {
//Crear el nuevo juego
var body *models.Juego
//Obtener los datos del body
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Revisa tu Body", "data": err.Error()})
}
//Crear el nuevo juego
var nuevoJuego *models.Juego
//Comprobar los datos del juego
//Nombre
if len(strings.TrimSpace(body.Nombre)) <= 0 || strings.TrimSpace(body.Nombre) == "" {
nuevoJuego.Nombre = body.Nombre
}
//Desarrollador
if len(strings.TrimSpace(body.Desarrollador)) <= 0 || strings.TrimSpace(body.Desarrollador) == "" {
nuevoJuego.Desarrollador= body.Desarrollador
}
//Precio
if len(body.Precio) < 0 {
nuevoJuego.Precio= body.Precio
}
//Modificar el Juego
exitoso, err := j.BaseDatos.ModificarJuego(nuevoJuego)
if err != nil || !exitoso {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
}
//Esto regresara el numero de estatus de que todo salio bien
return c.SendStatus(fiber.StatusAccepted)
}
- Eliminar un Juego:
//EliminarJuego Elimina un Juego por ID
func (j *juegosHandler) EliminarJuego(c *fiber.Ctx) error {
//Obtener el ID desde los parametros, es uno de los metodos de Fiber
ID := c.Params("id")
//Si el ID esta vacio, no se envio asi que regresa un error
if len(ID) < 0 {
//Esta es la forma de regresar errores en fiber
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Review your input"})
}
//Convertir ID a int
IntID, err := strconv.Atoi(ID)
if err != nil {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error converting in Integer", "data": err.Error()})
}
//Obtener datos del Juego
exitoso, err := j.BaseDatos.EliminarJuego(ID)
if err != nil || !exitoso {
return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
}
//Regresar Estatus de aceptado, todo salio bien.
return c.SendStatus(fiber.StatusAccepted)
}
Con esto terminamos con el handler de nuestro CRUD, Lo unico que falta es configurar el servidor con los metodos necesarios.
Configurar el Servidor Fiber
En nuestro main.go
deberiamos tener algo como esto:
func main() {
//Crear nuestra aplicación de Fiber
app := fiber.New()
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Hello, World 👋!")
})
log.Fatal(app.Listen(":3000"))
}
Para poder configurar con nuestros handlers, tendremos que agregar esto a nuestros import
import (
"log"
"github.com/gofiber/fiber/v2"
"gamelist/handlers"
)
Ahora crearemos un nuevo Handler
y agregaremos las rutas de nuestro CRUD:
func main() {
//Crear nuestra aplicación de Fiber
app := fiber.New()
nuevoHandler := handlers.NuevoJuegosHandler()
app.Get("/:id", nuevoHandler.ObtenerJuego)
app.Get("/", nuevoHandler.ObtenerTodosJuegos)
app.Post("/", nuevoHandler.CrearJuego)
app.Put("/", nuevoHandler.ModificarJuego)
app.Delete("/:id", nuevoHandler.EliminarJuego)
log.Fatal(app.Listen(":3000"))
}
¡Listo! ¡Terminamos nuestro Proyecto! 🤩
Eso es todo en este tutorial, Los siguientes pasos serian agregar middlewares
a nuestro proyecto y seguridad con JWT
.
Gracias por leer, cualquier cosa no dudes en preguntar.
Top comments (0)