Con l’introduzione in golang 1.16 del pacchetto embed
è stata data la possibilità di inserire direttamente nel programma Go compilato un frontend, rendendo la pubblicazione di un server fullstack molto più semplice utilizzando solamente un file.
SVILUPPO DI UNA APPLICAZIONE FULL-STACK
In questo articolo andremo a sviluppare un’applicazione server che risponderà, alla pressione dell’utente di un bottone in una pagina web, con delle frasi preimpostate. È un semplicissimo e rudimentale web server, senza l’interazione di database, etc…
Backend
Questo semplice HTTP API risponde, con delle frasi inserite nel codice, all’end-point /api/v1/embed
.
main.go
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math/rand"
"net/http"
)
func main() {
var port int
flag.IntVar(&port, "port", 8080, "La porta in ascolto è")
flag.Parse()
http.Handle("/api/v1/embed", http.HandlerFunc(getFrasiAPI))
log.Fatalln(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
}
type Law struct {
Titolo string `json:"titolo,omitempty"`
Enunciato string `json:"enunciato,omitempty"`
}
var FrasiAPI = []Law{
{
Titolo: "Frase 1",
Enunciato: "Questa è la frase 1, un esempio di risposta.",
},
{
Titolo: "Frase 2",
Enunciato: "Questa è la frase 2, un esempio di risposta.",
},
{
Titolo: "Frase 3",
Enunciato: "Questa è la frase 3, un esempio di risposta.",
},
}
func getFrasiAPI(w http.ResponseWriter, r *http.Request) {
randomFrasi := FrasiAPI[rand.Intn(len(FrasiAPI))]
j, err := json.Marshal(randomFrasi)
if err != nil {
http.Error(w, "non riesco a recuperare le frasi", http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "application/json")
io.Copy(w, bytes.NewReader(j))
}
Lanciando il comando go run main.go
e utilizzando curl
si può testare il server e la sua relativa risposta:
> curl http://localhost:8080/api/v1/embed
{"titolo":"Frase 3","enunciato":"Questa è la frase 3, un esempio di risposta."}
Frontend
In questo esempio viene utilizzato vuejs v2 al posto del nuovo v3. Se implementato con la nuova versione potrebbe aver bisogno di piccole modifiche al codice.
Per la creazione del frontend verrà utilizzato Vue Cli un’applicazione per creare un bootstrap del frontend. Per inizializzare la cartella si deve eseguire questo comando:
vue create frontend
Adesso abbiamo creato la cartella in cui risiederà il codice del frontend. Il prossimo passo da eseguire è la creazione del file main.js
contente il plugin axios e inizializzare il client:
import Vue from "vue";
import App from "./App.vue";
import axios from "axios";
import VueAxios from "vue-axios";
Vue.config.productionTip = false;
const client = axios.create({ baseURL: "/api/v1" });
Vue.use(VueAxios, client);
new Vue({
render: (h) => h(App),
}).$mount("#app");
A questo punto bisogna modificare anche il file App.vue
e impostare l’indirizzo del backend per recuperare le frasi.
<template>
<div id="app">
<button type="button" @click="getFrase()">Raccontami qualcosa</button>
<div v-if="frase != null">
<h1>{{ frase.titolo }}</h1>
<p>{{ frase.enunciato }}</p>
</div>
</div>
</template>
<script>
import Vue from "vue";
export default {
name: "App",
components: {},
data() {
return {
frase: null,
};
},
methods: {
getFrase() {
Vue.axios.get("/embed").then((response) => (this.frase = response.data));
},
},
};
</script>
Compilazione
Quando siamo nella cartella del frontend
possiamo compilare in versione produzione con questo comando:
yarn build
Questo comando creerà una nuova cartella in frontend/dist
contenente tutti i files appena creati. Infatti è ciò che andremo a servire attraverso il file principale del server di Go.
Per ottenere il risultato voluto si utilizzerà il modulo embed
per indicare quale directory vogliamo inglobare nel programma.
main.go
// ...
//go:embed frontend/dist
var frontend embed.FS
// ...
Successivamente impostiamo nella funzione main
di servire i files del frontend. Ci avvaliamo di alcuni helper:
fs.Sub
: Ritorna un nuovofs.FS
, cioè un subtree difs.FS
dato.http.FS
: Converte qualsiasifs.FS
in un formato utilizzabile dahttp.FileServer
.http.FileServer
: Crea un nuovo handler che serve i files utilizzati.
func main() {
// ...
stripped, err := fs.Sub(frontend, "frontend/dist")
if err != nil {
log.Fatalln(err)
}
frontendFS := http.FileServer(http.FS(stripped))
http.Handle("/", frontendFS)
// ...
}
Ora, se compiliamo il backend per la produzione con il seguente comando:
go build main.go
e lo avviamo come una qualsiasi applicazione:
go build main.go
Otterremo un webserver compreso di frontend.
Aprendo un browser e caricando la pagina http://120.0.0.1:8080
potremmo leggere una frase a caso ottenuta dall’endpoint api/v1/embed
.
Miglioramenti per il setup
Sfortunatamente, il lavoro non è finito. Abbiamo capito come creare, elaborare e servire i vari asset - backend e frontend - separatamente e compilarli in un unico file, ma bisogna trovare un modo per rendere automatizzato e sinergico lo sviluppo sia del frontend che del backend.
Non è molto comodo, tutte le volte che ci sono, seppur piccole, modifiche al codice, eseguire manualmente yarn build
e go build
.
Il servizio Vue CLI possiede un eccellente development server che lavora in combinazione con il comando noto yarn serve
. Questo permette un hot reloading del frontend ogniqualvolta il codice subisce delle modifiche. Ma il problema susssiste: stiamo lavorando con un backend e un frontend separati - Vue Development Server e Golang Backend Server - che hanno bisogno di due porte distinte per lavorare.
Potremmo essere tentati di aggiornare la porta del backend server con il comando:
go run main.go -port 8081
e di conseguenza aggiornare il client axios con una configurazione simile a:
const client = axios.create({
baseURL: "http://localhost:8081/api/v1",
});
Ma, se effettuiamo queste modifiche, non succederebbe nulla al pressione del tasto nel browser all’indirizzo http://localhost:8080
dovuto al fatto che non vengono rispettate le regole Same-Origin Policy, le quali bloccano le richieste API da indirizzi diversi rispetto a quelli del nostro frontend.
In Firefox, si otterrebbe una situazione del genere:
Fortunatamente ci sono delle soluzioni.
Opzione 1: Implementare CORS Middleware Nel Backend
Con questa opzione, non facciamo altro che dire al backend da che indirizzo URL accederemo con il frontend, il che permetterà di rispondere alla richiesta con gli opportuni CORS header. Un modulo per aiutarci in questo è github.com/rs/cors.
main.go
func main() {
//...
// Prima, definiamo un middleware basico per i CORS
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:8080"},
})
// Poi diciamo alla nostra route API di usare il middleware per i CORS
http.Handle("/api/v1/getFrasiAPI", corsMiddleware.Handler(http.HandlerFunc(getRandomLaw)))
//...
}
Adesso è il momento di eseguire il server API con il seguente comando:
go run main.go -port 8081
Se impostiamo il client axios all’indirizzo URL http://localhost:8081/api/v1
nel file main.js
, adesso otterremmo una risposta valida Access-Control-Allow-Origin
grazie alle impostazioni CORS impostate nel backend.
Per rendere lo sviluppo migliore del nostro setup distinguendo tra development e production per quanto riguarda il frontend Vue e utilizzare due endpoint API distinti - /api/v1/
per la build in produzione e http://localhost:8081/api/v1
per lo sviluppo - possiamo utilizzare le variabili .env supportate nativamente da Vue.
Si possono, così, specificare valori differenti creando un file .env.production
inserendo la configurazione da utilizzare in produzione, e, un altro file, .env.development
, contenente le variabili da utilizzare in fase di sviluppo.
Nota: Mai inserire in questi files password o dati sensibili in quanto chi utilizza l’app può leggerne il contenuto.
.env.production
FRONTEND_API_BASE_URL=/api/v1
.env.development
FRONTEND_API_BASE_URL=http://localhost:8081/api/v1
E aggiornare il client axios per istruirlo ad utilizzare le nuove variabili nel file main.js
const client = axios.create({
baseURL: process.env.FRONTEND_API_BASE_URL,
});
Finalmente, il nostro frontend utilizzerà l’appropriato endpoint API sia in produzione che in fase di sviluppo.
Opzione 2: Vue Dev Server Proxy
Un’altra opzione è quella di utilizzare il server di sviluppo Vue come un proxy per instradare il traffico verso il backend. All’interno del file vue.config.js, possiamo specificare l’indirizzo del backend da utilizzare e anche le regole per stabilire quale traffico far passare attraverso il proxy.
Ciò ci permette di creare una configurazione la quale invia il traffico che inizia con /api
al nostro server che è in funzione all’indirizzo http://localhost:8081
. In questo modo, il browser penserà di aver a che fare solo con l’indirizzo http://localhost:8080
, evitando così i problemi della regola Same-Origin Policy.
vue.config.js
module.exports = {
devServer: {
proxy: {
"^/api": {
target: "http://localhost:8081",
changeOrigin: true,
},
},
},
};
E, di conseguenza, bisogna istruire il client axios ad utilizzare /api/v1
come base per l’URL:
main.js
const client = axios.create({
baseURL: "/api/v1",
});
Di conseguenza, se stiamo lavorando in fase di sviluppo, il server Vue inoltrerà in modo trasparente tutte le richieste del client axios a http://localhost:8081
. In produzione, il server Golang riceverà il traffico e lo instraderà al corretto endpoint.
Opzione 3: Usare il Server Golang per Servire i Files del Frontend in Development
Nelle altre due opzioni, durante lo sviluppo sia il server backend che il server frontend devono essere attivati ognuno con il comando che gli compete, quindi go run main.go
e yarn dev
.
Vue dispone di un comando specifico che ricompila i files automaticamente quando questi vengono modificati. Questo è possibile se utilizziamo l’argomento --watch
. Per prima cosa va modificato il file package.json
per includere l’opzione watch
nello script:
"scripts": {
"watch": "vue-cli-service build --watch"
},
Tale comando compila e crea la cartella frontend/dist
.
Siccome il nostro fine è quello di permettere al server backend di raggiungere l’ultima versione disponibile nel disco, possiamo farci aiutare da os.DirFS
, una implementazione alternativa di fs.FS
. Modifichiamo il file main.go
nel seguente modo:
func main() {
//...
http.Handle("/", http.FileServer(http.FS(os.DirFS("frontend/dist"))))
}
ma con l’attuale modifica esiste ancora un problema da risolvere: la solita differenziazione tra ambiente di sviluppo e di produzione. Ci vengono in auto, questa volta, le build tags di Go.
Per sfruttare i tags abbiamo bisogno di creare due ulteriori funzioni per implementare gli assets del frontend.
Per production build:
// +build prod
package main
import (
"embed"
"io/fs"
)
//go:embed frontend/dist
var addFrontend embed.FS
func getFrontend() fs.FS {
f, err := fs.Sub(addFrontend, "frontend/dist")
if err != nil {
panic(err)
}
return f
}
e per development:
// +build prod
package main
import (
"embed"
"io/fs"
)
//go:embed frontend/dist
var addFrontend embed.FS
func getFrontend() fs.FS {
f, err := fs.Sub(addFrontend, "frontend/dist")
if err != nil {
panic(err)
}
return f
}
Non rimane altro da fare che modificare la funzione main
per permettere al server in faso di avvio di scegliere quale assets caricare:
func main() {
//...
frontend := getFrontend()
http.Handle("/", http.FileServer(http.FS(frontend)))
//...
}
L’utilizzo è molto semplice, basta, infatti, avviare per l’ambiente di sviluppo development il server con il comando seguente:
cd frontend
yarn watch
# In un altro terminale
go run .
mentre in produzione:
cd frontend
yarn build
cd ..
go build -tags prod
Conclusioni
Abbiamo visto tre modalità differenti per sviluppare un’applicazione con un server backend e frontend e amalgamarle tra di loro per uno sviluppo più semplice e veloce.
Ma quali sono le differenze principali tra gli esempi riportati in precedenza?
- Opzione 1: è la più complessa ma è la più generica e flessibile tra le varie proposte. Dà la possibilità di risolvere svariati casi, come ad esempio servire il backend e il frontend usando URL differenti.
- Opzione 2: è la più semplice in quanto non richiede nessuna modifica sostanziale al codice del server scritto in Go. Tuttavia, è più specifico per l’utilizzo di Vue e potrebbe non funzionare con altri framework.
- Opzione 3: Ci permette di utilizzare un ambiente “più realistico”, in quanto la nostra app è responsabile nel servire gli assets del frontend sia in fase di sviluppo che in produzione. Tuttavia, si utilizzano le build tags che sono una tecnica avanzata e non tutti gli sviluppatori potrebbero esserne familiari.
Links
- Repo Github contenente il codice del backend e frontend