Implementare un servizio Goa
Panoramica
Una volta che il design del servizio è completato, occorre far girare il tool goa
per autogenerare il codice:
goa gen <import path del design package>
Il tool goa
crea una cartella gen
contenente tutto il codice generato e la sua
documentazione. Il codice generato segue il principio della Clean Architecture, e
crea ogni servizio in un package separato. Oltre a questo la cartella gen
contiene
sotto-cartelle per ogni protocollo di trasporto (http
e/o grpc
):
gen
├── service1
│ ├── client.go # Service client struct
│ ├── endpoints.go # Endpoints agnostici al protocollo di trasporto
│ └── service.go # interfaccia del Service
├── service2
│ ├── client.go
│ ├── endpoints.go
│ └── service.go
├── ...
├── grpc
│ ├── service1
│ │ ├── client # Codice del client gRPC
│ │ ├── pb # gRPC protobuf files autogenerati
│ │ └── server # Codice del server gRPC
│ ├── service2
│ └── ...
│ ├── ...
│ └── cli
└── http
├── service1
│ ├── client # Codice del client HTTP
│ └── server # Codice del server HTTP
├── service2
│ └── ...
├── ...
├── cli
│ └── calc
│ └── cli.go
├── openapi.json # Specifiche OpenAPI 2 (anche dette "swagger")
├── openapi.yaml
├── openapi3.json # Specifiche OpenAPI 3
└── openapi3.yaml
Clean Architecture Layers
Prima di tuffarci nei dettagli implementativi occorre capire i livelli coinvolti nel pattern Clean Architecture. Per ogni servizio Goa crea un transport, un endpoint e un service layer.
Transport Layer
Il livello di trasporto si occupa della codifica e decodifica di request e responses
e ne valida il contenuto. Nel caso di HTTP il codice generato da Goa fa leva su
encoder e decoder differenti che sono forniti a runtime, rendendo possibile l’uso
di diversi encoders e decoders per servizi diversi e addirittura per metodi diversi.
Vedi la sezione HTTP Encoding per più informazioni. Questo layer
è implementato dai package sotto le cartelle http
e grpc
.
Endpoint Layer
Il livello endpoint è il collante fra trasporto e servizio. Rappresenta ogni metodo del servizio usando funzioni Go con una firma comune, permettendo di implementare un comportamento ortogonale che si applica a tutti i metodi (ciò si definisce anche transport agnostic middleware). La firma di ogni metodo è:
func (s *Service) Method(ctx context.Context, payload interface{}) (response interface{}, err error)
L’endpoint layer è implementato nel file endpoints.go
dentro la cartella di ogni servizio.
Service Layer
Infine, il service layer è dove vive la logica di business. Goa genera l’interfaccia per ogni servizio e l’utente ne fornisce l’implementazione.
il service layers è implementato nel file service.go
sotto ogni servizio.
Riassumendo
Le request ricevute dall’implementazione HTTP o gRPC sono decodificate dai rispettivi transport layer e sono passate agli endpoint layer, che a loro volta chiamano il service layer per eseguire la logica di business. Il service layer infine ritorna la response all’endpoint layer che è codificato dal transport layer e mandato al client.
TRANSPORT ENDPOINT SERVICE
+-------------+ +--------------+
Request | Decodifica | | Middleware |
---------->| e +------>| e +----------+
| Validazione | | Type casting | v
+-------------+ +--------------+ +-----------+
| Logica di |
| Business |
+-----------+ +--------------+ +----+------+
Response | | | Middleware | |
<----------+ Codifica |<------+ e |<---------+
| | | Type casting |
+-----------+ +--------------+
Nota: il transport layers può aggiungere altri middleware al flusso della request, non rappresentato nel seguente diagramma.
Implementazione dei Servizi
Implementare un servizio consiste nell’implementazione della corrispondente interfaccia
generata da Goa nel file service.go
. Ad esempio, dato il seguente service design:
package design
import . "goa.design/goa/v3/dsl"
var _ = Service("calc", func() {
Method("Multiply", func() {
Payload(func() {
Attribute("a", Int, "Primo operando")
Attribute("b", Int, "secondo operando")
})
Result(Int)
HTTP(func() {
GET("/multiply/{a}/{b}")
})
GRPC(func() {})
})
})
E il seguente setup:
mkdir calc; cd calc
go mod init calc
mkdir design
# crea dal design/design.go col contenuto
# di cui sopra
goa gen calc/design
Goa genera la seguente interfaccia in gen/calc/service.go
:
type Service interface {
Multiply(context.Context, *MultiplyPayload) (res int, err error)
}
Una possibile implementazione può essere:
type svc struct {}
func (s *svc) Multiply(ctx context.Context, p *calcsvc.MultiplyPayload) (int, error) {
return p.A + p.B, nil
}
La struct che implementa l’interfaccia può essere usata per istanziare i
service endpoints con la funzione NewEndpoints
, generata in endpoints.go
:
func NewEndpoints(s Service) *Endpoints {
return &Endpoints{
Multiply: NewMultiplyEndpoint(s),
}
}
Questa funzione semplicemente wrappa l’interfaccia del service con gli endpoints che possono essere forniti al trasport layer generato per esporre gli endpoints stessi al protocollo di trasporto.
s := &svc{}
endpoints := calc.NewEndpoints(s)
Creazione di Server HTTP
Per HTTP questo si fa con la funzione New
generata nel file http/<servizio>/server/server.go
:
func New(
e *calc.Endpoints,
mux goahttp.Muxer,
decoder func(*http.Request) goahttp.Decoder,
encoder func(context.Context, http.ResponseWriter) goahttp.Encoder,
errhandler func(context.Context, http.ResponseWriter, error),
formatter func(context.Context, err error) goahttp.Statuser,
) *Server
Creare un server HTTP richiede sia i service endpoint che l’HTTP router, il decoder
e l’encoder HTTP. errhandler
è la funzione chiamata dal codice generato quando
la codifica o la decodifica falliscono per qualche motivo. I formatter
rende possibile
sovrascrivere come Goa formatta gli errori ritornati dai service methods prima dell’ encoding.
Possono essere entrambi nil
, in questo caso il codice generato andrà in panic
in caso di errori di encoding (non può accadere con gli encoder di default di Goa) e gli altri errori
saranno formattati usando la struct ServiceError.
Il package http di Goa contiene le implementazioni di default per il router, l’encoder e il decoder, rendendolo molto conveniente per creare server HTTP.
mux := goahttp.NewMuxer()
dec := goahttp.RequestDecoder
enc := goahttp.ResponseEncoder
svr := calcsvr.New(endpoints, mux, dec, enc, nil, nil)
L’ultimo step necessario per configurare il server HTTP consiste nel chiamare le
funzioni Mount
generate.
func Mount(mux goahttp.Muxer, h *Server) {
MountMultiplyHandler(mux, h.Multiply)
}
L’oggetto mux è un handler HTTP standard che può essere usato per servire richieste HTTP:
calcsvr.Mount(mux, svr)
s := &http.Server{Handler: mux}
s.ListenAndServe()
Il codice completo per creare un servizio HTTP è il seguente:
package main
import (
"context"
"net/http"
goahttp "goa.design/goa/v3/http"
"calc/gen/calc"
"calc/gen/http/calc/server"
)
type svc struct{}
func (s *svc) Multiply(ctx context.Context, p *calc.MultiplyPayload) (int, error) {
return p.A + p.B, nil
}
func main() {
s := &svc{} // Crea il servizio
endpoints := calc.NewEndpoints(s) // Crea gli endpoints
mux := goahttp.NewMuxer() // Crea il muxer HTTP
dec := goahttp.RequestDecoder // Imposta il request decoder HTTP
enc := goahttp.ResponseEncoder // Imposta response encoder HTTP
svr := server.New(endpoints, mux, dec, enc, nil, nil) // Crea il server HTTP di Goa
server.Mount(mux, svr) // Monta il server Goa sul mux
httpsvr := &http.Server{ // Crea il server HTTP go
Addr: "localhost:8081", // Configura l'indirizzo di ascolto del server
Handler: mux, // Set request handler
}
if err := httpsvr.ListenAndServe(); err != nil { // Fai partire il server HTTP
panic(err)
}
}
Nota: Il codice qui presente è pensato per aiutare a capire come interfacciarsi col codice generato e non va assolutamente usato così com’è. In particolare in un codice reale si sposterebbe probabilmente la logica di business nel suo package riservato e si implementerebbe una gestione degli errori adeguata.
Creare dei server gRPC
La creazione di server gRPC seguen un pattern simile ai server HTTP. La funzione
New
generata nel file gen/grpc/<service>/server/server.go
crea il server:
func New(e *calc.Endpoints, uh goagrpc.UnaryHandler) *Server {
return &Server{
MultiplyH: NewMultiplyHandler(e.Multiply, uh),
}
}
Tale funzione accetta gli endpoints e un handler gRPC opzionale che permetter di configurare il gRPC. L’implementazione di default usa le seguenti opzioni gRPC:
func NewMultiplyHandler(endpoint goa.Endpoint, h goagrpc.UnaryHandler) goagrpc.UnaryHandler {
if h == nil {
h = goagrpc.NewUnaryHandler(endpoint, DecodeMultiplyRequest, EncodeMultiplyResponse)
}
return h
}
Una volta creato, il gRPC server di Goa è registrato come un server gRPC qualsiasi usando
la funzione Register<Service>Server
generata:
svr := server.New(endpoints, nil)
grpcsrv := grpc.NewServer()
calcpb.RegisterCalcServer(grpcsrv, svr)
L’avvio del server gRPC è fatto nella solita maniera, per esempio:
lis, err := net.Listen("tcp", "localhost:8082")
if err != nil {
panic(err)
}
if err := srv.Serve(lis); err != nil {
panic(err)
}
Il codice completo per creare un servizio gRPC di esempio è il seguente:
package main
import (
"context"
"net"
"google.golang.org/grpc"
"calc/gen/calc"
calcpb "ca/gen/grpc/calc/pb"
"calc/gen/grpc/calc/server"
)
type svc struct{}
func (s *svc) Multiply(ctx context.Context, p *calc.MultiplyPayload) (int, error) {
return p.A + p.B, nil
}
func main() {
s := &svc{}
endpoints := calc.NewEndpoints(s)
svr := server.New(endpoints, nil)
grpcsrv := grpc.NewServer()
calcpb.RegisterCalcServer(grpcsrv, svr)
lis, err := net.Listen("tcp", "localhost:8082")
if err != nil {
panic(err)
}
if err := grpcsrv.Serve(lis); err != nil {
panic(err)
}
}
Un singolo servizio Goa può esporre sia endpoint HTTP che gRPC nello stesso momento. In questo caso la struct degli endpoints è creata una sola volta e condivisa fra i server HTTP e gRPC.
Sovrascrivere i default
Una delle cose da capire sul codice generato è che è progettato per essere completamente sovrascrivibile. Il package del servizio generato fornisce funzioni che rendono possibile la creazione di endpoint individuali, per esempio:
gen/calc/endpoints.go
func NewMultiplyEndpoint(s Service) goa.Endpoint {
return func(ctx context.Context, req interface{}) (interface{}, error) {
p := req.(*MultiplyPayload)
return s.Multiply(ctx, p)
}
}
La funziona generata NewEndpoints
semplicemente chiama la creazione di ogni
singolo endpointe e ritorna una struct che contiene ogni riferimento agli endpoints.
Il codice dell’utente può sovrascrivere ogni endpoint, dato che tutti i campi della
struct sono pubblici:
type Endpoints struct {
Multiply goa.Endpoint
}
In maniera simile sia i server HTTP che gRPC espongono dei campi pubblici che contengono i rispettivi handlers:
HTTP (gen/http/calc/server/server.go
):
type Server struct {
Multiply http.Handler
// ...
}
gRPC (gen/grpc/calc/server/server.go
):
type Server struct {
MultiplyH goagrpc.UnaryHandler
// ...
}
Il codice dell’utente può quindi sovrascrivere gli handler, dato che i campi sono pubblici.
La funzione New
che crea i server semplicemente delega alle funzioni specifiche degli endpoint
che sono pubbliche e possono essere chiamate individualmente:
HTTP (gen/http/calc/server/server.go
):
func NewMultiplyHandler(
endpoint goa.Endpoint,
mux goahttp.Muxer,
decoder func(*http.Request) goahttp.Decoder,
encoder func(context.Context, http.ResponseWriter) goahttp.Encoder,
errhandler func(context.Context, http.ResponseWriter, error),
formatter func(context.Context, err error) goahttp.Statuser,
) http.Handler
gRPC (gen/grpc/calc/server/server.go
):
func NewMultiplyHandler(endpoint goa.Endpoint, h goagrpc.UnaryHandler) goagrpc.UnaryHandler
Oltretutto con HTTP si può anche sovrascrivere l’encoder, il decoder o perfino il muxer sostituendo l’implementazione di default con la propria.