Prerequisiti

  • Installare Go Programming nel tuo sistema operativo
  • Comprendere il linguaggio Basic Go
  • Possedere familiarità con API RESTFUL e JSON

Cosa e perché usare Gin Gonic

Gin Gonic è un framework HTTP, leggero e uno dei framework web Golang più famosi. Gin è un router di richieste HTTP ad alte prestazioni e afferma di essere 40 volte più veloce di Martini, un framework simile in Go.

Implementazione
Progetto di installazione e dipendenze

Per iniziare un nuovo progetto GO con il supporto di GO Module eseguire il seguente comando:

go mod init

Questo comando crea un file chiamato “go.mod” che tiene traccia delle dipendenze del tuo progetto.

Crea un Makefile per la configurazione, l’installazione delle dipendenze e la directory dei file per il progetto di installazione.

init-dependency:
go get -u github.com/antonfisher/nested-logrus-formatter
go get -u github.com/gin-gonic/gin
go get -u golang.org/x/crypto
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres
go get -u github.com/sirupsen/logrus
go get -u github.com/joho/godotenv

Eseguire questo comando per il download e aggiungere le dipendenze a go.mod:

make init-dependency

e quindi la struttura delle cartelle assomiglierà a questa:

Folder structure for this RESTFUL API project

La configurazione del file .env.

PORT=8080
# Application
APPLICATION_NAME=simple-restful-api

# Database
DB_DSN="host=localhost user=root password=root dbname=gin-gonic-api port=5432"

# Logging
LOG_LEVEL=DEBUG

Utilizzare Init Logger con Golang logrus per semplificare il tracciamento degli  errori o qualsiasi cosa accada nell’applicazione.

package config

import (
nested "github.com/antonfisher/nested-logrus-formatter"
"github.com/joho/godotenv"
log "github.com/sirupsen/logrus"
"os"
)

func InitLog() {

log.SetLevel(getLoggerLevel(os.Getenv("LOG_LEVEL")))
log.SetReportCaller(true)
log.SetFormatter(&nested.Formatter{
HideKeys: true,
FieldsOrder: []string{"component", "category"},
TimestampFormat: "2006-01-02 15:04:05",
ShowFullLevel: true,
CallerFirst: true,
})

}

func getLoggerLevel(value string) log.Level {
switch value {
case "DEBUG":
return log.DebugLevel
case "TRACE":
return log.TraceLevel
default:
return log.InfoLevel
}
}

Nel codice soprastante, impostare il livello di log su DEBUG seguendo ciò che è impostato in .env. Implementare un formattatore annidato aiuta ad avere dei log più leggibili.

Configura la connessione al database

Configurazione per la connessione al database (postgresql): utilizzare il driver GORM per la connessione al database e supporta i database MySQL, PostgreSQL, SQLite, SQL Server.

package config

import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
"log"
"os"
)

func ConnectToDB() *gorm.DB {
var err error
dsn := os.Getenv("DB_DSN")

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("Error connecting to database. Error: ", err)
}

return db
}

Definire costante

Definire costanti per stringhe comuni e riutilizzabili, soprattutto quando si restituisce una stringa come chiave di risposta o messaggio su JSON.

package constant

type ResponseStatus int
type Headers int
type General int

// Constant Api
const (
Success ResponseStatus = iota + 1
DataNotFound
UnknownError
InvalidRequest
Unauthorized
)

func (r ResponseStatus) GetResponseStatus() string {
return [...]string{"SUCCESS", "DATA_NOT_FOUND", "UNKNOWN_ERROR", "INVALID_REQUEST", "UNAUTHORIZED"}[r-1]
}

func (r ResponseStatus) GetResponseMessage() string {
return [...]string{"Success", "Data Not Found", "Unknown Error", "Invalid Request", "Unauthorized"}[r-1]
}

Definire modello o DAO con GORM e DTO

package dao

import (
"gorm.io/gorm"
"time"
)

type BaseModel struct {
CreatedAt time.Time `gorm:"->:false;column:created_at" json:"-"`
UpdatedAt time.Time `gorm:"->:false;column:updated_at" json:"-"`
DeletedAt gorm.DeletedAt `gorm:"->:false;column:deleted_at" json:"-"`
}

type Role struct {
ID int `gorm:"column:id; primary_key; not null" json:"id"`
Role string `gorm:"column:role" json:"role"`
BaseModel
}

type User struct {
ID int `gorm:"column:id; primary_key; not null" json:"id"`
Name string `gorm:"column:name" json:"name"`
Email string `gorm:"column:email" json:"email"`
Password string `gorm:"column:password;->:false" json:"-"`
Status int `gorm:"column:status" json:"status"`
RoleID int `gorm:"column:role_id;not null" json:"role_id"`
Role Role `gorm:"foreignKey:RoleID;references:ID" json:"role"`
BaseModel
} 
 
    
column->:false : disabled read from db
json:”-” : ignore a field  

In questa API è possibile inserire semplicemente una struttura dati tra utente e ruolo:

package dto

type ApiResponse[T any] struct {
ResponseKey string `json:"response_key"`
ResponseMessage string `json:"response_message"`
Data T `json:"data"`
}

Utilità per creare una risposta API

La risposta fornita dall’API sarà considerata di buona qualità se possiede una struttura dati coesa. Ciò rende più agevole la comprensione sia per il front-end che per qualsiasi altro utente. Pertanto, implementa un’utility che garantisca una formattazione uniforme per le risposte di ciascun endpoint dell’API.

package pkg

import (
"gin-gonic-api/app/constant"
"gin-gonic-api/app/domain/dto"
)

func Null() interface{} {
return nil
}

func BuildResponse[T any](responseStatus constant.ResponseStatus, data T) dto.ApiResponse[T] {
return BuildResponse_(responseStatus.GetResponseStatus(), responseStatus.GetResponseMessage(), data)
}

func BuildResponse_[T any](status string, message string, data T) dto.ApiResponse[T] {
return dto.ApiResponse[T]{
ResponseKey: status,
ResponseMessage: message,
Data: data,
}
}

Aggiungi errore personalizzato e gestore errori

È consigliabile creare una funzione comune per la gestione delle condizioni di errore in Go Appilcation.

package pkg

import (
"errors"
"fmt"
"gin-gonic-api/app/constant"
)

func PanicException_(key string, message string) {
err := errors.New(message)
err = fmt.Errorf("%s: %w", key, err)
if err != nil {
panic(err)
}
}

func PanicException(responseKey constant.ResponseStatus) {
PanicException_(responseKey.GetResponseStatus(), responseKey.GetResponseMessage())
}

func PanicHandler(c *gin.Context) {
if err := recover(); err != nil {
str := fmt.Sprint(err)
strArr := strings.Split(str, ":")

key := strArr[0]
msg := strings.Trim(strArr[1], " ")

switch key {
case
constant.DataNotFound.GetResponseStatus():
c.JSON(http.StatusBadRequest, BuildResponse_(key, msg, Null()))
c.Abort()
case
constant.Unauthorized.GetResponseStatus():
c.JSON(http.StatusUnauthorized, BuildResponse_(key, msg, Null()))
c.Abort()
default:
c.JSON(http.StatusInternalServerError, BuildResponse_(key, msg, Null()))
c.Abort()
}
}
}

Definire l’errore utilizzando error.New(message) e fmt.Errorf con %w si rivelerà prezioso per implementare il concetto di wrapping degli errori. Questa pratica ci consente di arricchire l’errore con ulteriore contesto riguardante la sua origine, come il nome della funzione chiamata. Il gestore dell’errore nel nostro programma può quindi analizzare e suddividere il messaggio di errore in una chiave di risposta e un messaggio vero e proprio, agevolando così la trasformazione del tutto in un formato JSON durante la restituzione al chiamante.

Implementare percorso, Hanlder/Controller, service e repository

Inizializzare un router predefinito Gin:

package router

import (
"gin-gonic-api/config"
"github.com/gin-gonic/gin"
)

func Init(init *config.Initialization) *gin.Engine {

router := gin.New()
router.Use(gin.Logger())
router.Use(gin.Recovery())

api := router.Group("/api")
{
user := api.Group("/user")
user.GET("", init.UserCtrl.GetAllUserData)
user.POST("", init.UserCtrl.AddUserData)
user.GET("/:userID", init.UserCtrl.GetUserById)
user.PUT("/:userID", init.UserCtrl.UpdateUserData)
user.DELETE("/:userID", init.UserCtrl.DeleteUser)
}

return router
}

Per definire un router, sono necessari due parametri fondamentali: l’URL dell’endpoint e il relativo gestore o controller. Nel codice precedente, l’endpoint veniva articolato in due livelli, usando /api e /user, come se raggruppasse l’intero percorso e vi aggiungesse un prefisso.

Ciascuna richiesta proveniente da un percorso specifico viene gestita dal controller precedentemente assegnato nel router, per poi essere instradata verso il servizio responsabile della logica aziendale.

La funzione Init viene chiamata quando la struttura dipendente da essa viene iniettata; in Google Wire, ciò è denominato iniettore, mentre nell’ambito della programmazione orientata agli oggetti è spesso denominato costruttore.

package controller

import (
"gin-gonic-api/app/service"
"github.com/gin-gonic/gin"
)

type UserController interface {
GetAllUserData(c *gin.Context)
AddUserData(c *gin.Context)
GetUserById(c *gin.Context)
UpdateUserData(c *gin.Context)
DeleteUser(c *gin.Context)
}

type UserControllerImpl struct {
svc service.UserService
}

func (u UserControllerImpl) GetAllUserData(c *gin.Context) {
u.svc.GetAllUser(c)
}

func (u UserControllerImpl) AddUserData(c *gin.Context) {
u.svc.AddUserData(c)
}

func (u UserControllerImpl) GetUserById(c *gin.Context) {
u.svc.GetUserById(c)
}

func (u UserControllerImpl) UpdateUserData(c *gin.Context) {
u.svc.UpdateUserData(c)
}

func (u UserControllerImpl) DeleteUser(c *gin.Context) {
u.svc.DeleteUser(c)
}

func UserControllerInit(userService service.UserService) *UserControllerImpl {
return &UserControllerImpl{
svc: userService,
}
}

Come menzionato in precedenza, un pacchetto di servizi costituisce un punto centrale per la gestione della logica aziendale all’interno di un’applicazione. Funge essenzialmente da orchestratore nel nostro programma, gestendo operazioni come esecuzione di query sui dati in un database, mappatura delle richieste e gestione degli errori.


package service

import (
"gin-gonic-api/app/constant"
"gin-gonic-api/app/domain/dao"
"gin-gonic-api/app/pkg"
"gin-gonic-api/app/repository"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"net/http"
"strconv"
)

type UserService interface {
GetAllUser(c *gin.Context)
GetUserById(c *gin.Context)
AddUserData(c *gin.Context)
UpdateUserData(c *gin.Context)
DeleteUser(c *gin.Context)
}

type UserServiceImpl struct {
userRepository repository.UserRepository
}

func (u UserServiceImpl) UpdateUserData(c *gin.Context) {
defer pkg.PanicHandler(c)

log.Info("start to execute program update user data by id")
userID, _ := strconv.Atoi(c.Param("userID"))

var request dao.User
if err := c.ShouldBindJSON(&request); err != nil {
log.Error("Happened error when mapping request from FE. Error", err)
pkg.PanicException(constant.InvalidRequest)
}

data, err := u.userRepository.FindUserById(userID)
if err != nil {
log.Error("Happened error when get data from database. Error", err)
pkg.PanicException(constant.DataNotFound)
}

data.RoleID = request.RoleID
data.Email = request.Email
data.Name = request.Password
data.Status = request.Status
u.userRepository.Save(&data)

if err != nil {
log.Error("Happened error when updating data to database. Error", err)
pkg.PanicException(constant.UnknownError)
}

c.JSON(http.StatusOK, pkg.BuildResponse(constant.Success, data))
}

func (u UserServiceImpl) GetUserById(c *gin.Context) {
defer pkg.PanicHandler(c)

log.Info("start to execute program get user by id")
userID, _ := strconv.Atoi(c.Param("userID"))

data, err := u.userRepository.FindUserById(userID)
if err != nil {
log.Error("Happened error when get data from database. Error", err)
pkg.PanicException(constant.DataNotFound)
}

c.JSON(http.StatusOK, pkg.BuildResponse(constant.Success, data))
}

func (u UserServiceImpl) AddUserData(c *gin.Context) {
defer pkg.PanicHandler(c)

log.Info("start to execute program add data user")
var request dao.User
if err := c.ShouldBindJSON(&request); err != nil {
log.Error("Happened error when mapping request from FE. Error", err)
pkg.PanicException(constant.InvalidRequest)
}

hash, _ := bcrypt.GenerateFromPassword([]byte(request.Password), 15)
request.Password = string(hash)

data, err := u.userRepository.Save(&request)
if err != nil {
log.Error("Happened error when saving data to database. Error", err)
pkg.PanicException(constant.UnknownError)
}

c.JSON(http.StatusOK, pkg.BuildResponse(constant.Success, data))
}

func (u UserServiceImpl) GetAllUser(c *gin.Context) {
defer pkg.PanicHandler(c)

log.Info("start to execute get all data user")

data, err := u.userRepository.FindAllUser()
if err != nil {
log.Error("Happened Error when find all user data. Error: ", err)
pkg.PanicException(constant.UnknownError)
}

c.JSON(http.StatusOK, pkg.BuildResponse(constant.Success, data))
}

func (u UserServiceImpl) DeleteUser(c *gin.Context) {
defer pkg.PanicHandler(c)

log.Info("start to execute delete data user by id")
userID, _ := strconv.Atoi(c.Param("userID"))

err := u.userRepository.DeleteUserById(userID)
if err != nil {
log.Error("Happened Error when try delete data user from DB. Error:", err)
pkg.PanicException(constant.UnknownError)
}

c.JSON(http.StatusOK, pkg.BuildResponse(constant.Success, pkg.Null()))
}

func UserServiceInit(userRepository repository.UserRepository) *UserServiceImpl {
return &UserServiceImpl{
userRepository: userRepository,
}
}

Il servizio che stiamo implementando richiede la definizione di cinque metodi fondamentali:

  • GetAllUser(c *gin.Context): per ottenere tutti i dati degli utenti.
  • GetUserById(c *gin.Context): per ottenere i dati di un utente tramite il suo ID.
  • AddUserData(c *gin.Context): per creare nuovi dati utente.
  • UpdateUserData(c *gin.Context): per aggiornare i dati esistenti di un utente.
  • DeleteUser(c *gin.Context): per eliminare i dati di un utente in base al suo ID.

È importante notare che nel nostro servizio non comunichiamo direttamente con il database, ma invece interagiamo tramite un repository. Pertanto, il nostro servizio dipende dal componente del repository per gestire le operazioni di accesso ai dati.

Questa struttura modulare consente una maggiore separazione delle responsabilità, facilitando la manutenzione e la scalabilità del nostro sistema.


package repository

import (
"gin-gonic-api/app/domain/dao"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)

type UserRepository interface {
FindAllUser() ([]dao.User, error)
FindUserById(id int) (dao.User, error)
Save(user *dao.User) (dao.User, error)
DeleteUserById(id int) error
}

type UserRepositoryImpl struct {
db *gorm.DB
}

func (u UserRepositoryImpl) FindAllUser() ([]dao.User, error) {
var users []dao.User

var err = u.db.Preload("Role").Find(&users).Error
if err != nil {
log.Error("Got an error finding all couples. Error: ", err)
return nil, err
}

return users, nil
}

func (u UserRepositoryImpl) FindUserById(id int) (dao.User, error) {
user := dao.User{
ID: id,
}
err := u.db.Preload("Role").First(&user).Error
if err != nil {
log.Error("Got and error when find user by id. Error: ", err)
return dao.User{}, err
}
return user, nil
}

func (u UserRepositoryImpl) Save(user *dao.User) (dao.User, error) {
var err = u.db.Save(user).Error
if err != nil {
log.Error("Got an error when save user. Error: ", err)
return dao.User{}, err
}
return *user, nil
}

func (u UserRepositoryImpl) DeleteUserById(id int) error {
err := u.db.Delete(&dao.User{}, id).Error
if err != nil {
log.Error("Got an error when delete user. Error: ", err)
return err
}
return nil
}

func UserRepositoryInit(db *gorm.DB) *UserRepositoryImpl {
db.AutoMigrate(&dao.User{})
return &UserRepositoryImpl{
db: db,
}
}

Per ogni operazione che coinvolge l’interazione con il database, abbiamo adottato l’utilizzo di GORM come ORM. All’interno del pacchetto del repository, nelle funzioni di inizializzazione (Init), abbiamo incluso un’azione dedicata per eseguire la migrazione del modello/DAO nella tabella corrispondente nel database.

Questo approccio ci consente di sfruttare le funzionalità di GORM per la gestione degli oggetti e delle relazioni, semplificando l’interazione con il database. La migrazione del modello nella tabella del database assicura che la struttura dei dati sia allineata con la nostra definizione di modello, garantendo coerenza e integrità nel sistema.

In sostanza, GORM funge da ponte tra il nostro codice e la persistenza dei dati nel database, offrendo un’astrazione efficace per semplificare le operazioni CRUD (Create, Read, Update, Delete) e facilitare la gestione del modello dei dati.

Produci l’inizializzazione di ciascun componente utilizzando Google Wire

L’utilizzo manuale di inserimento delle dipendenze in Go può diventare problematico, specialmente a mano a mano che il programma cresce in dimensioni e complessità. Per semplificare questa gestione, possiamo fare affidamento sulla libreria Google Wire per automatizzare il processo di inserimento delle dipendenze nei nostri componenti.

Nel file init.go, Google Wire viene utilizzato per gestire l’inizializzazione delle dipendenze. Questo approccio semplifica il codice, riducendo la necessità di inserire manualmente ogni componente. La libreria si occupa automaticamente della risoluzione delle dipendenze e dell’inizializzazione degli oggetti necessari.

L’utilizzo di Google Wire rende il codice più pulito, manutenibile e facilita l’aggiunta di nuovi componenti o modifiche al sistema senza dover modificare manualmente tutte le dipendenze collegate.

In sintesi, l’utilizzo di Google Wire è un modo efficace per gestire l’inserimento delle dipendenze in maniera automatizzata, semplificando lo sviluppo e la manutenzione del codice, specialmente quando il programma diventa più grande e complesso.

 
package config

import (
"gin-gonic-api/app/controller"
"gin-gonic-api/app/repository"
"gin-gonic-api/app/service"
)

type Initialization struct {
userRepo repository.UserRepository
userSvc service.UserService
UserCtrl controller.UserController
RoleRepo repository.RoleRepository
}

func NewInitialization(userRepo repository.UserRepository,
userService service.UserService,
userCtrl controller.UserController,
roleRepo repository.RoleRepository) *Initialization {
return &Initialization{
userRepo: userRepo,
userSvc: userService,
UserCtrl: userCtrl,
RoleRepo: roleRepo,
}
}

injector.go

// go:build wireinject
//go:build wireinject
// +build wireinject

package config

import (
"gin-gonic-api/app/controller"
"gin-gonic-api/app/repository"
"gin-gonic-api/app/service"
"github.com/google/wire"
)

var db = wire.NewSet(ConnectToDB)

var userServiceSet = wire.NewSet(service.UserServiceInit,
wire.Bind(new(service.UserService), new(*service.UserServiceImpl)),
)

var userRepoSet = wire.NewSet(repository.UserRepositoryInit,
wire.Bind(new(repository.UserRepository), new(*repository.UserRepositoryImpl)),
)

var userCtrlSet = wire.NewSet(controller.UserControllerInit,
wire.Bind(new(controller.UserController), new(*controller.UserControllerImpl)),
)

var roleRepoSet = wire.NewSet(repository.RoleRepositoryInit,
wire.Bind(new(repository.RoleRepository), new(*repository.RoleRepositoryImpl)),
)

func Init() *Initialization {
wire.Build(NewInitialization, db, userCtrlSet, userServiceSet, userRepoSet, roleRepoSet)
return nil
}

Assicurati di includere i tag “//go:build wireinject” e “//+build wireinject” nel tuo file. Questi tag sono riconosciuti da Google Wire durante la generazione. Dopo aver inserito i tag, esegui il seguente comando. Questo genererà un file chiamato “wire gen.go”:

 wire gen gin-gonic-api/config

il file main.go sarà così

package main

import (
"gin-gonic-api/app/router"
"gin-gonic-api/config"
"github.com/joho/godotenv"
"os"
)

func init() {
godotenv.Load()
config.InitLog()
}

func main() {
port := os.Getenv("PORT")

init := config.Init()
app := router.Init(init)

app.Run(":" + port)
}

Testare l’API

Nota: Prima di procedere, assicurati di inizializzare i dati dei ruoli nel database e garantisci che i dati degli utenti siano associati correttamente alla tabella dei ruoli.

Puoi eseguire il seguente script SQL per inserire i dati necessari:

execute this sql :
INSERT INTO roles(id, “role”, created_at, updated_at, deleted_at)
VALUES
(1, ‘ADMIN’, current_timestamp, null, null),
(2, ‘USER’, current_timestamp, null, null)

Dopo aver inizializzato i dati, procederemo a testare l’endpoint appena creato. Puoi eseguire i test utilizzando Curl o qualsiasi piattaforma API come Postman o Insomnia.

Inizieremo testando l’endpoint per ottenere tutti i dati degli utenti con il seguente comando:

curl --location --request GET 'http://localhost:8080/api/user'

La risposta dovrebbe assomigliare a questa:

{
"response_key": "SUCCESS",
"response_message": "Success",
"data": [
{
"id": 2,
"name": "wayne",
"email": "wayne@mail.ic",
"status": 0,
"role": {
"id": 1,
"role": "ADMIN"
}
},
{
"id": 3,
"name": "wayne",
"email": "wayne@mail.ic",
"status": 0,
"role": {
"id": 1,
"role": "ADMIN"
}
}
]
}

Quindi, proviamo l’endpoint a creare utenti di dati con il metodo POST

curl --location --request POST 'http://localhost:8080/api/user' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "wayne",
"email": "wayne@mail.id",
"password": "plain_password",
"role_id": 1
}'

La risposta dovrebbe essere questa:

{
"response_key": "SUCCESS",
"response_message": "Success",
"data": {
"id": 8,
"name": "wayne",
"email": "wayne@mail.id",
"status": 0,
"role_id": 1
}
}

Gli altri endpoint come aggiornamento, eliminazione, puoi testarli in autonomia.

 

Vi aspettiamo al prossimo workshop gratuito per parlarne dal vivo insieme a Mihai Canea!

Clicca qui per registrarti!

 

Non perderti, ogni mese, gli approfondimenti sulle ultime novità in campo digital! Se vuoi sapere di più, visita la sezione “Blog“ sulla nostra pagina!