GoSuda

Configurazione di un server NATS integrato in un'applicazione Go e struttura della comunicazione in-process

By prravda views ...

Per iniziare

Informazioni su NATS

Le applicazioni software e i servizi devono scambiarsi dati. NATS è un'infrastruttura che consente tale scambio di dati, suddivisi sotto forma di messaggi. La definiamo "message oriented middleware".

Con NATS, gli sviluppatori di applicazioni possono:

  • Costruire in modo semplice applicazioni client-server distribuite e scalabili.
  • Memorizzare e distribuire dati in tempo reale in modo generale. Ciò può essere realizzato in modo flessibile across vari ambienti, linguaggi, provider cloud e sistemi on-premises.

What is NATS, NATS docs

  • NATS è un message broker costruito in Go.

NATS incorporato

Se la tua applicazione è scritta in Go e se si adatta al tuo caso d'uso e agli scenari di deployment, puoi persino incorporare un server NATS all'interno della tua applicazione.

Embedding NATS, NATS docs

  • Una particolarità di NATS è che, per le applicazioni costruite in Go, supporta la modalità embedded.
  • In altre parole, anziché il metodo tipico dei message broker, ovvero avviare un server broker separato e comunicare con esso tramite i client dell'applicazione, è possibile incorporare il broker stesso (embed) nell'applicazione realizzata in Go.

Vantaggi e casi d'uso di NATS incorporato

  • Poiché esiste un video su YouTube ben curato, si rimanda al link del video.
  • Anche senza distribuire un server di message broker separato, è possibile creare un'applicazione modular monolith e ottenere la separation of concerns, traendo vantaggio dalla possibilità di incorporare NATS in modalità embedded. Inoltre, diventa possibile il single binary deployment.
  • Può essere utile non solo per la platform with no network (wasm), ma anche per le offline-first application.

Esempio nella documentazione ufficiale

 1package main
 2
 3import (
 4    "fmt"
 5    "time"
 6
 7    "github.com/nats-io/nats-server/v2/server"
 8    "github.com/nats-io/nats.go"
 9)
10
11func main() {
12    opts := &server.Options{}
13
14    // Inizializza un nuovo server con le opzioni
15    ns, err := server.NewServer(opts)
16
17    if err != nil {
18        panic(err)
19    }
20
21    // Avvia il server tramite goroutine
22    go ns.Start()
23
24    // Attende che il server sia pronto per le connessioni
25    if !ns.ReadyForConnections(4 * time.Second) {
26        panic("not ready for connection")
27    }
28
29    // Connessione al server
30    nc, err := nats.Connect(ns.ClientURL())
31
32    if err != nil {
33        panic(err)
34    }
35
36    subject := "my-subject"
37
38    // Sottoscrizione al subject
39    nc.Subscribe(subject, func(msg *nats.Msg) {
40        // Stampa i dati del messaggio
41        data := string(msg.Data)
42        fmt.Println(data)
43
44        // Arresta il server (opzionale)
45        ns.Shutdown()
46    })
47
48    // Pubblica dati sul subject
49    nc.Publish(subject, []byte("Hello embedded NATS!"))
50
51    // Attende l'arresto del server
52    ns.WaitForShutdown()
53}
  • Si tratta dell'esempio di NATS incorporato riportato dalla documentazione ufficiale di NATS, ma seguendo il codice di esempio la comunicazione non avviene in modalità embedding.
1Every 2.0s: netstat -an | grep 127.0.0.1         pravdalaptop-home.local: 02:34:20
2                                                                     in 0.017s (0)
3...
4tcp4       0      0  127.0.0.1.4222         127.0.0.1.63769        TIME_WAIT
  • Eseguendo watch 'netstat -an | grep 127.0.0.1' per monitorare la rete che transita su localhost (127.0.0.1) e avviando il file go con go run ., si può osservare l'aggiunta di nuove richieste di rete che partono dalla porta predefinita di NATS, 4222.

Configurazioni corrette per la modalità embedding

  • Per comunicare nella modalità embedded come previsto sono necessarie le seguenti due opzioni.

    • Client: è necessario inserire l'opzione InProcessServer.
    • Server: è necessario impostare esplicitamente il flag DontListen di Server.Options a true.
  • Tali aspetti non sono ufficialmente documentati; l'origine di questa funzionalità è rintracciabile nella seguente PR.

    This PR adds three things:

    1. InProcessConn() function to Server which builds a net.Pipe to get a connection to the NATS server without using TCP sockets
    2. DontListen option which tells the NATS server not to listen on the usual TCP listener
    3. startupComplete channel, which is closed right before we start AcceptLoop, and readyForConnections will wait for it

    The main motivation for this is that we have an application that can run either in a monolith (single-process) mode or a polylith (multi-process) mode. We'd like to be able to use NATS for both modes for simplicity, but the monolith mode has to be able to cater for a variety of platforms where opening socket connections either doesn't make sense (mobile) or just isn't possible (WASM). These changes will allow us to use NATS entirely in-process instead.

    An accompanying PR nats-io/nats.go#774 adds support to the client side.

    This is my first PR to this project so apologies in advance if I've missed anything obvious anywhere.

    /cc @nats-io/core

Esempio funzionante per la modalità embedded

 1package main
 2
 3import (
 4	"fmt"
 5	"time"
 6
 7	"github.com/nats-io/nats-server/v2/server"
 8	"github.com/nats-io/nats.go"
 9)
10
11func main() {
12	opts := &server.Options{
13		// per configurare il server NATS incorporato
14		// impostare DonListen a true
15		DontListen: true,
16	}
17
18	// Inizializza un nuovo server con le opzioni
19	ns, err := server.NewServer(opts)
20
21	if err != nil {
22		panic(err)
23	}
24
25	// Avvia il server tramite goroutine
26	go ns.Start()
27
28	// Attende che il server sia pronto per le connessioni
29	if !ns.ReadyForConnections(10 * time.Second) {
30		panic("not ready for connection")
31	}
32
33	// Connessione al server tramite connessione in-process
34	nc, err := nats.Connect(ns.ClientURL(), nats.InProcessServer(ns))
35
36	if err != nil {
37		panic(err)
38	}
39
40	subject := "my-subject"
41
42	// Sottoscrizione al subject
43	nc.Subscribe(subject, func(msg *nats.Msg) {
44		// Stampa i dati del messaggio
45		data := string(msg.Data)
46		fmt.Println(data)
47
48		// Arresta il server (opzionale)
49		ns.Shutdown()
50	})
51
52	// Pubblica dati sul subject
53	nc.Publish(subject, []byte("Hello embedded NATS!"))
54
55	// Attende l'arresto del server
56	ns.WaitForShutdown()
57}
58
1Every 2.0s: netstat -an | grep 127.0.0.1         pravdalaptop-home.local: 02:37:50
2                                                                     in 0.023s (0)
3...no additional logs
4
  • Ora si può osservare che non si verifica alcun network hop aggiuntivo, come previsto.

Sotto il cofano

TL;DR

diagram1

  • Si tratta di un sequence diagram che mostra quali funzioni vengono eseguite e in che modo quando il codice viene eseguito da main.go; i punti principali sono i seguenti.
    • Tramite DontListen: true, il server omette la fase di ascolto dei client denominata AcceptLoop.
    • Se l'opzione InProcessServer del Connect del client è attiva, viene creata una connessione in-memory tramite net.Pipe, e l'estremità della pipe viene restituita al client come tipo net.Conn.
    • Client e server comunicano in-process attraverso tale connessione.

Server

AcceptLoop

1// nats-server/server/server.go
2
3// Attende i client.
4if !opts.DontListen {
5	s.AcceptLoop(clientListenReady)
6}
  • Innanzitutto, quando DontListen è true, la fase di ascolto dei client denominata AcceptLoop viene omessa.
 1// nats-server/server/server.go
 2
 3// AcceptLoop è esportata per facilitare i test.
 4func (s *Server) AcceptLoop(clr chan struct{}) {
 5	// Se uscissimo prima che il listener sia configurato correttamente,
 6	// assicuriamoci di chiudere il canale.
 7	defer func() {
 8		if clr != nil {
 9			close(clr)
10		}
11	}()
12
13	if s.isShuttingDown() {
14		return
15	}
16
17	// Cattura uno snapshot delle opzioni del server.
18	opts := s.getOpts()
19
20	// Configura lo stato che consente lo shutdown
21	s.mu.Lock()
22	hp := net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port))
23	l, e := natsListen("tcp", hp)
24	s.listenerErr = e
25	if e != nil {
26		s.mu.Unlock()
27		s.Fatalf("Error listening on port: %s, %q", hp, e)
28		return
29	}
30	s.Noticef("Listening for client connections on %s",
31		net.JoinHostPort(opts.Host, strconv.Itoa(l.Addr().(*net.TCPAddr).Port)))
32
33	// Segnala che TLS è abilitato.
34	if opts.TLSConfig != nil {
35		s.Noticef("TLS required for client connections")
36		if opts.TLSHandshakeFirst && opts.TLSHandshakeFirstFallback == 0 {
37			s.Warnf("Clients that are not using \"TLS Handshake First\" option will fail to connect")
38		}
39	}
40
41	// Se il server è stato avviato con RANDOM_PORT (-1), opts.Port sarebbe uguale
42	// a 0 all'inizio di questa funzione. Quindi dobbiamo ottenere la porta effettiva
43	if opts.Port == 0 {
44		// Riscrive la porta risolta nelle opzioni.
45		opts.Port = l.Addr().(*net.TCPAddr).Port
46	}
47
48	// Ora che la porta è stata impostata (se era RANDOM), imposta
49	// Host/Port delle INFO del server con i valori di Options o
50	// ClientAdvertise.
51	if err := s.setInfoHostPort(); err != nil {
52		s.Fatalf("Error setting server INFO with ClientAdvertise value of %s, err=%v", opts.ClientAdvertise, err)
53		l.Close()
54		s.mu.Unlock()
55		return
56	}
57	// Tiene traccia degli URL di connessione dei client. Potrebbero servire più avanti.
58	s.clientConnectURLs = s.getClientConnectURLs()
59	s.listener = l
60
61	go s.acceptConnections(l, "Client", func(conn net.Conn) { s.createClient(conn) },
62		func(_ error) bool {
63			if s.isLameDuckMode() {
64				// Segnala che non stiamo accettando nuovi client
65				s.ldmCh <- true
66				// Ora attende lo Shutdown...
67				<-s.quitCh
68				return true
69			}
70			return false
71		})
72	s.mu.Unlock()
73
74	// Comunica al chiamante che siamo pronti
75	close(clr)
76	clr = nil
77}
  • Va notato che la funzione AcceptLoop esegue i passaggi seguenti. Le parti relative alla comunicazione di rete, come TLS o hostPort, non sono necessarie per la comunicazione in-process e possono quindi essere omesse.

Client

InProcessServer

 1
 2// nats-go/nats.go
 3
 4// Connect tenta di connettersi al sistema NATS.
 5// L'url può contenere la semantica username/password. es. nats://derek:pass@localhost:4222
 6// Sono supportati anche array separati da virgole, es. urlA, urlB.
 7// Le opzioni partono dai valori predefiniti ma possono essere sovrascritte.
 8// Per connettersi alla porta websocket di un server NATS, usare lo schema `ws` o `wss`, come
 9// `ws://localhost:8080`. Si noti che gli schemi websocket non possono essere combinati con altri (nats/tls).
10func Connect(url string, options ...Option) (*Conn, error) {
11	opts := GetDefaultOptions()
12	opts.Servers = processUrlString(url)
13	for _, opt := range options {
14		if opt != nil {
15			if err := opt(&opts); err != nil {
16				return nil, err
17			}
18		}
19	}
20	return opts.Connect()
21}
 1// nats-go/nats.go
 2
 3// Options può essere usato per creare una connessione personalizzata.
 4type Options struct {
 5	// Url rappresenta un singolo url del server NATS a cui il client
 6	// si connetterà. Se è impostata anche l'opzione Servers, diventa
 7	// il primo server nell'array Servers.
 8	Url string
 9
10	// InProcessServer rappresenta un server NATS in esecuzione all'interno
11	// dello stesso processo. Se è impostato, tenteremo di connetterci
12	// direttamente al server anziché usare connessioni TCP esterne.
13	InProcessServer InProcessConnProvider
14
15	//...
16}
1// nats-go/nats.go
2
3type InProcessConnProvider interface {
4	InProcessConn() (net.Conn, error)
5}
  • La funzione Connect, che realizza la connessione tra il server NATS e il client NATS, accetta l'URL del client e le opzioni di connessione; la struct Options, che raccoglie tali opzioni, contiene il campo InProcessServer di tipo interface InProcessConnProvider.
1// main.go of example code
2
3// Inizializza un nuovo server con le opzioni
4ns, err := server.NewServer(opts)
5
6//...
7
8// Connessione al server tramite connessione in-process
9nc, err := nats.Connect(ns.ClientURL(), nats.InProcessServer(ns))
  • Quando il client NATS esegue Connect e passa nats.InProcessServer(ns) nel campo InProcessServer,
 1// nats-go/nats.go
 2
 3// InProcessServer è un'Option che tenta di stabilire una connessione verso un server NATS
 4// in esecuzione all'interno del processo, anziché effettuare il dial tramite TCP.
 5func InProcessServer(server InProcessConnProvider) Option {
 6	return func(o *Options) error {
 7		o.InProcessServer = server
 8		return nil
 9	}
10}
  • l'InProcessServer dell'opzione viene sostituito con il server NATS incorporato,
 1// nats-go/nats.go
 2
 3// createConn si connette al server e avvolge le
 4// strutture bufio appropriate. Gestisce correttamente il caso in cui
 5// sia già presente una connessione esistente.
 6func (nc *Conn) createConn() (err error) {
 7	if nc.Opts.Timeout < 0 {
 8		return ErrBadTimeout
 9	}
10	if _, cur := nc.currentServer(); cur == nil {
11		return ErrNoServers
12	}
13
14	// Se abbiamo un riferimento a un server in-process, stabiliamo una
15	// connessione usando quello.
16	if nc.Opts.InProcessServer != nil {
17		conn, err := nc.Opts.InProcessServer.InProcessConn()
18		if err != nil {
19			return fmt.Errorf("failed to get in-process connection: %w", err)
20		}
21		nc.conn = conn
22		nc.bindToNewConn()
23		return nil
24	}
25
26	//...
27}
  • Questa interface viene utilizzata nella funzione createConn, che crea la connessione: se l'opzione InProcessServer non è nil (quindi valida), viene eseguito il metodo InProcessConn dell'InProcessServer contenuto nell'opzione,
 1// nats-server/server/server.go
 2
 3// InProcessConn restituisce una connessione in-process al server,
 4// evitando la necessità di usare un listener TCP per la connettività locale
 5// all'interno dello stesso processo. Può essere usato indipendentemente
 6// dallo stato dell'opzione DontListen.
 7func (s *Server) InProcessConn() (net.Conn, error) {
 8	pl, pr := net.Pipe()
 9	if !s.startGoRoutine(func() {
10		s.createClientInProcess(pl)
11		s.grWG.Done()
12	}) {
13		pl.Close()
14		pr.Close()
15		return nil, fmt.Errorf("failed to create connection")
16	}
17	return pr, nil
18}
  • Viene quindi invocato il InProcessConn implementato nel server.
  • Questa funzione viene chiamata da nats.go, il client Go di NATS, quando l'InProcessServer dell'nc (connessione NATS) non è nil: crea la connessione (net.Conn) e la associa alla connessione del server.

Interface Go definita dal consumer

A type implements an interface by implementing its methods. There is no explicit declaration of intent, no "implements" keyword. Implicit interfaces decouple the definition of an interface from its implementation, which could then appear in any package without prearrangement.

Interfaces are implemented implicitly, A Tour of Go

If a type exists only to implement an interface and will never have exported methods beyond that interface, there is no need to export the type itself.

Generality, Effective Go

  • Questo design delle interface rispecchia bene le cosiddette consumer defined interface e lo structural typing (duck typing) tipici di Go, per cui si è deciso di presentare anche questo argomento.
 1// nats-go/nats.go
 2
 3// Options può essere usato per creare una connessione personalizzata.
 4type Options struct {
 5	// Url rappresenta un singolo url del server NATS a cui il client
 6	// si connetterà. Se è impostata anche l'opzione Servers, diventa
 7	// il primo server nell'array Servers.
 8	Url string
 9
10	// InProcessServer rappresenta un server NATS in esecuzione all'interno
11	// dello stesso processo. Se è impostato, tenteremo di connetterci
12	// direttamente al server anziché usare connessioni TCP esterne.
13	InProcessServer InProcessConnProvider
14
15	//...
16}
1// nats-go/nats.go
2
3type InProcessConnProvider interface {
4	InProcessConn() (net.Conn, error)
5}
  • Torniamo al codice. Nel client nats.go, il campo dell'opzione InProcessServer è definito come interface InProcessConnProvider, che prevede solo InProcessConn.
 1// nats-server/server/server.go
 2
 3// InProcessConn restituisce una connessione in-process al server,
 4// evitando la necessità di usare un listener TCP per la connettività locale
 5// all'interno dello stesso processo. Può essere usato indipendentemente
 6// dallo stato dell'opzione DontListen.
 7func (s *Server) InProcessConn() (net.Conn, error) {
 8	pl, pr := net.Pipe()
 9	if !s.startGoRoutine(func() {
10		s.createClientInProcess(pl)
11		s.grWG.Done()
12	}) {
13		pl.Close()
14		pr.Close()
15		return nil, fmt.Errorf("failed to create connection")
16	}
17	return pr, nil
18}
  • Tuttavia, il tipo che viene inserito è il Server di nats-server, che svolge molte altre funzioni oltre a InProcessConn.
  • Questo perché, in questo contesto, l'interesse del client riguarda solo se è stata fornita l'interface InProcessConn o meno, mentre il resto non è rilevante.
  • Pertanto, il client nats.go utilizza solo l'interface InProcessConnProvider, un'interface definita dal consumer che prevede unicamente la funzionalità InProcessConn() (net.Conn, error).

Conclusione

  • Abbiamo trattato in sintesi la modalità embedded di NATS, il suo funzionamento e la consumer defined interface di Go che si può osservare nel codice di NATS.
  • Auspico che queste informazioni siano utili a chi utilizza NATS con questi scopi, e con questo concludo l'articolo.