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.jscontente 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 nuovo fs.FS, cioè un subtree di fs.FS dato.
  • http.FS: Converte qualsiasi fs.FS in un formato utilizzabile da http.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.

VueJs e GO in un unico file

VueJs e GO in un unico file

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:

VueJs e GO errore CORS

VueJs e GO errore CORS

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.

VueJs e GO errore CORS

Collegamento alla pagina http://localhost:8080 e funzionamento CORS

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.
  • Repo Github contenente il codice del backend e frontend