Komunikace přes TCP, UDP i unixové sokety v programovacím jazyce Go

4. 5. 2023
Doba čtení: 23 minut

Sdílet

 Autor: Go lang
Programovací jazyk Go se velmi často používá pro tvorbu síťových aplikací. Dnes si ukážeme, jak snadno lze v jazyku Go realizovat komunikaci jak přes TCP či UDP, tak i přes unixové sokety.

Obsah

1. Komunikace přes unixové sokety v programovacím jazyce Go

2. Základní nástroje, které využijeme: netstat, nclsof

3. Komunikace přes TCP sokety (stream)

4. Komunikace přes UDP sokety (datagram)

5. „Jmenné prostory“ TCP a UDP

6. Použití Unixových soketů (streamdatagram)

7. Realizace jednoduchého klienta komunikujícího přes TCP sokety v jazyku Go

8. Základní kostra serveru komunikujícího přes TCP sokety v jazyku Go

9. Vylepšení klienta i serveru – uzavírání prostředků a reakce na případné chyby

10. Simulace zpomalené reakce serveru

11. Otestování chování serveru

12. Server akceptující větší množství klientů realizovaný pomocí gorutin

13. Otestování chování nové implementace serveru

14. Klient komunikující přes unixové sokety naprogramovaný v Go

15. Server komunikující přes unixové sokety naprogramovaný v Go

17. Otestování komunikace mezi klientem a serverem

18. Obsah druhé části článku

19. Repositář s demonstračními příklady

20. Odkazy na Internetu

1. Komunikace přes unixové sokety v programovacím jazyce Go

Programovací jazyk Go se velmi často používá pro tvorbu síťových aplikací. V dnešním článku si ukážeme, jak snadno je možné v jazyku Go realizovat komunikaci mezi serverem a klienty jak přes TCP či UDP (tedy s využitím síťových rozhraní, včetně lokálního síťového rozhraní), tak i přes unixové sokety. Přitom si ukážeme i některé základní způsoby využití dalších utility: nc, lsof a netstat.

Nezávisle na tom, zda je pro komunikaci použito síťové rozhraní (včetně localhostu) nebo unixové sokety, se rozlišuje mezi tzv. spojovou službou a komunikací pomocí datagramů. Spojová služba je realizována buď s využitím protokolu TCP nebo přes unixové sokety (které podporují i využití datagramů). Před zahájením komunikace je nutné navázat spojení. Toto spojení vždy navazuje klient, který se připojuje k serveru (ovšem odpojit se může jakákoli strana). Po navázání spojení může probíhat přenos dat (obousměrný) a přitom jsou všechna přenesená data potvrzována (z pohledu programátora automaticky). Pokud nastane situace, kdy nejsou data potvrzena, jsou poslána znovu (opět bez zásahu programátora). Zaručeno je i pořadí doručení dat.

Naproti tomu existuje (rostoucí) počet aplikací, pro které je zajištění spojové služby zbytečně komplikované a v případě TCP i příliš zatěžující pro síť. Pokud není nutné zaručit pořadí zpráv nebo jejich potvrzování, lze komunikovat přes datagramy. To je případ síťové komunikace přes protokol UDP resp. (opět) přes unixové sokety (zde je ovšem pořadí zaručeno). U UDP může dojít i k duplikaci dat, zatímco u Unixových soketů většinou nikoli.

2. Základní nástroje, které využijeme: netstat, nclsof

V dnešním článku sice vytvoříme několik jednoduchých klientů a serverů naprogramovaných v jazyce Go, ovšem kromě toho využijeme i některé (dnes již prakticky) standardní systémové nástroje; konkrétně nástroje nazvané netstat, nc a lsof:

  1. netstat je nástroj, který je možné využít pro zjištění síťových připojení popř. i použití Unixových soketů. Moderní varianty tohoto nástroje podporují velké množství přepínačů, ovšem my využijeme jen malou podskupinu těchto přepínačů.
  2. nc neboli netcat je takřka univerzální nástroj při práci s TCP a UDP, ale (jak ostatně uvidíme dále) i s Unixovými sokety. Pomocí tohoto nástroje lze snadno realizovat TCP/UDP klienta i server, ovšem možnosti tohoto nástroje jsou ve skutečnosti mnohem větší.
  3. lsof je nástroj, který dokáže vypsat seznam otevřených souborů a popř. i další vlastnosti těchto souborů. Tento nástroj využijeme v souvislosti s Unixovými sokety, které se z pohledu uživatele skutečně tváří jako speciální typy souborů.

3. Komunikace přes TCP sokety (stream)

Síťové aplikace se vytváří na různých úrovních abstrakce – buď se pouze otevřou připojení (například s využitím dále využitých Unixových soketů) a následný komunikační protokol je naprogramován přímo vývojářem, nebo se naopak využije nějaký již existující protokol na vyšší vrstvě (pravděpodobně nejznámějším příkladem ze současnosti je protokol HTTP atd.). Nejprve si ukážeme komunikaci mezi jednoduchým klientem a serverem na nižší úrovni, kdy náš komunikační protokol (na aplikační úrovni) bude spočívat v přenosu jediného bajtu přes TCP popř. přes UDP. Později namísto TCP/UDP použijeme unixové sokety.

Poznámka: na nižších vrstvách (pokud půjdeme stále níže, tak konkrétně na vrstvě TCP, IP a Ethernetu) bude samozřejmě komunikace složitější, od toho ovšem budeme do jisté míry odstíněni standardními knihovnami (balíčky) programovacího jazyka Go.

Nejprve na prvním terminálu spustíme nástroj nc, který bude nakonfigurován jako server přijímající (naslouchající) na portu 1234. Aby nástroj nc naslouchal požadavkům, musíme použít přepínač -l neboli „listen“:

$ nc -l -v localhost 1234
 
Listening on localhost 1234

Následně ve druhém terminálu navážeme spojení se serverem naslouchajícím na portu 1234:

$ nc -v localhost 1234
 
Connection to localhost 1234 port [tcp/*] succeeded!

A na prvním terminálu se zobrazí informace o navázání spojení a navíc o portu otevřeném klientem:

Connection received on localhost 35636

Pokud nyní budete na tento terminál zapisovat nějaké zprávy, bude je server přijímat a opisovat je na svůj (první) terminál:

aaa
bbb
cc
ddd

Na třetím terminálu si pak můžeme zobrazit podrobnější informace o komunikačním kanále mezi serverem a klientem:

$ netstat -tunp | grep :1234
 
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp        0      0 127.0.0.1:1234          127.0.0.1:35636         ESTABLISHED 231176/nc
tcp        0      0 127.0.0.1:35636         127.0.0.1:1234          ESTABLISHED 232495/nc
Poznámka: povšimněte si, že se zobrazí oba porty, po kterých komunikace probíhá. Pro první proces (což je v tomto případě server) se porty vypíšou v opačném pořadí, než je tomu u klienta, protože je použito pořadí místní_port:vzdálený_port.

4. Komunikace přes UDP sokety (datagram)

Nyní si vyzkoušíme podobnou komunikaci, ovšem nikoli zajištěnou s využitím TCP (stream), ale použijeme namísto toho protokol UDP (datagram). Nástroj nc komunikaci přes UDP taktéž podporuje, ovšem musíme použít přepínač -u:

$ nc -l -u -v localhost 1234
 
Bound on localhost 1234
Poznámka: samotné hlášení je odlišné – používá se zde slovo „bound“ a nikoli „listening“, což souvisí s céčkovým API k soketům a odlišnou sémantikou jejich použití.

V dalším terminálu se k serveru připojíme:

$ nc -u -v localhost 1234
 
Connection to localhost 1234 port [udp/*] succeeded!

Na terminálu serveru se taktéž vypíše informace o připojení:

Connection received on localhost 45470

A propojené porty opět získáme a necháme si vypsat nástrojem netstat:

$ netstat -tunp | grep :1234
 
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
udp        0      0 127.0.0.1:45470         127.0.0.1:1234          ESTABLISHED 233385/nc
udp        0      0 127.0.0.1:1234          127.0.0.1:45470         ESTABLISHED 233118/nc
Poznámka: nyní se v prvním sloupci vypíše „udp“ a nikoli „tcp“.

5. „Jmenné prostory“ TCP a UDP

Porty TCP a UDP jsou sice identifikovány celými čísly 0..65535, ovšem jedná se o odlišné „jmenné prostory“. Proto nelze navázat spojení se serverem naslouchajícím na UDP portu 1234 z klienta pokoušejícího se připojit na TCP port 1234:

$ nc -l -u -v localhost 1234
 
Bound on localhost 1234
 
 
 
$ nc -v localhost 1234
 
nc: connect to localhost port 1234 (tcp) failed: Connection refused

Platí to pochopitelně i naopak:

$ nc -l -v localhost 1234
 
Listening on localhost 1234
 
 
 
$ nc -u -v localhost 1234
(proces je ihned ukončen)
Poznámka: povšimněte si odlišného chování při NEnavázání připojení.

6. Použití Unixových soketů (streamdatagram)

Namísto protokolu TCP či UDP můžeme pro komunikaci mezi procesy použít unixové sokety, které taktéž existují ve variantě stream a datagram (tj. způsob komunikace do značné míry odpovídá TCP resp. UDP). Unixové sokety lze využít pro komunikaci mezi procesy běžícími na jednom počítači a vlastní komunikace probíhá přes strukturu spravovanou jádrem, která je z uživatelského prostoru viditelná jako speciální typ souboru. Podívejme se nyní na způsob spuštění serveru, který pro komunikaci otevře soket se jménem /tmp/test.socket (což je onen speciální typ souboru):

$ nc -l -U -v /tmp/test.socket
 
Bound on /tmp/test.socket
Listening on /tmp/test.socket

Samotný soket, na který se mají klienti připojit, bude vypadat následovně:

$ lsof /tmp/test.socket
 
COMMAND    PID     USER   FD   TYPE             DEVICE SIZE/OFF       NODE NAME
nc      235825 ptisnovs    3u  unix 0x0000000000000000      0t0 1288302629 /tmp/test.socket type=STREAM

Na druhém terminálu se k tomuto serveru připojíme přes unixový soket:

$ nc -U -v /tmp/test.socket

Připojení k serveru je potvrzeno na prvním terminálu zprávou:

Connection received on /tmp/test.socket

A obousměrná komunikace je nástrojem lsof nalezena takto:

$ lsof /tmp/test.socket
 
COMMAND    PID     USER   FD   TYPE             DEVICE SIZE/OFF       NODE NAME
nc      235825 ptisnovs    3u  unix 0x0000000000000000      0t0 1288302629 /tmp/test.socket type=STREAM
nc      235825 ptisnovs    4u  unix 0x0000000000000000      0t0 1288302630 /tmp/test.socket type=STREAM

Podívejme se nyní na vlastnosti souboru /tmp/test.socket:

$ ls -la /tmp/test.socket 
 
srwxrwxr-x 1 ptisnovs ptisnovs 0 Apr 30 15:26 /tmp/test.socket

Z prvního znaku „s“ je patrné, že se skutečně jedná o soket.

Komunikace přes datagram soket vypadá poněkud odlišně, protože se ve skutečnosti použijí dva sokety, jeden explicitně pojmenovaný a druhý vytvořený klientem (a klientem uzavíraný):

$ nc -l -u -U -v /tmp/test.socket
Bound on /tmp/test.socket
 
$ nc -u -U -v /tmp/test.socket
Bound on /tmp/nc.XXXXRAlNkA
 
$ lsof /tmp/test.socket
COMMAND    PID     USER   FD   TYPE             DEVICE SIZE/OFF       NODE NAME
nc      238174 ptisnovs    3u  unix 0x0000000000000000      0t0 1288403891 /tmp/test.socket type=DGRAM
 
$ lsof /tmp/nc.XXXXRAlNkA
COMMAND    PID     USER   FD   TYPE             DEVICE SIZE/OFF       NODE NAME
nc      238687 ptisnovs    4u  unix 0x0000000000000000      0t0 1288437146 /tmp/nc.XXXXRAlNkA type=DGRAM
Poznámka: tyto informace nám budou prozatím postačovat k tomu, abychom mohli komunikaci přes unix sokety realizovat v jazyce Go.

7. Realizace jednoduchého klienta komunikujícího přes TCP sokety v jazyku Go

Síťové aplikace se vytváří na různých úrovních abstrakce – buď se pouze otevřou připojení (například s využitím Unix socketů) a následný komunikační protokol je naprogramován přímo vývojářem, nebo se naopak využije nějaký již existující protokol na vyšší síťové vrstvě (HTTP atd.). Nejprve si ukážeme komunikaci mezi jednoduchým klientem a serverem na nižší úrovni, kdy náš komunikační protokol (na aplikační úrovni) bude spočívat v přenosu jediného bajtu přes TCP popř. přes UDP.

Samotná implementace klienta bude poměrně přímočará a bude se skládat z těchto kroků:

  1. Navázání připojení s využitím konstruktoru net.Dial(protokol, adresa), který je popsán na adrese https://golang.org/pkg/net/#Dial. Použitý protokol je specifikován řetězcem; konkrétně se může jednat o konstanty „tcp“, „tcp4“, „tcp6“, „udp“, „udp4“, „udp6“, „ip“, „ip4“, „ip6“, „unix“, „unixgram“ popř. „unixpacket“. V příkladu zvolíme „tcp“, který bude funkční v sítích s IPv4 i IPv6 (nebo pochopitelně v kombinovaných sítích).
  2. Přečtení n bajtů metodou Read(b []byte) (n int, err error) (konkrétní příjemce se liší podle toho, jaké připojení jsme deklarovali v konstruktoru, ovšem tato metoda bude vždy podporována). Povšimněte si, že této metodě je nutné předat řez (slice) a nikoli pole bajtů (to je v tomto případě nekompatibilní datový typ). Z tohoto důvodu v našem příkladu použijeme trik pole[:], kterým se vytvoří řez ukazující na celé pole (přesněji řez bude ukazovat na první prvek pole a jeho délka bude odpovídat délce pole).
  3. Přečtené pole bajtů se následně vytiskne, přičemž server implementovaný v rámci další kapitoly je naprogramován takovým způsobem, aby posílal jen jediný bajt.

Úplný zdrojový kód tohoto demonstračního příkladu bude vypadat následovně:

package main
 
import (
        "fmt"
        "net"
)
 
func main() {
        conn, err := net.Dial("tcp", "localhost:1234")
        if err != nil {
                println("Connection refused!")
        } else {
                var b [1]byte
                n, err := conn.Read(b[:])
                if err != nil {
                        println("No response!")
                } else {
                        if n == 1 {
                                fmt.Printf("Received %d byte: %v\n", n, b)
                        } else {
                                fmt.Printf("Received %d bytes: %v\n", n, b)
                        }
                }
        }
}

O realizovaném připojení si můžeme zjistit i další informace, například lokální i vzdálenou adresu s využitím metod Conn.LocalAddr() a Conn.RemoteAddr(). Tyto adresy převedeme do tisknutelného tvaru metodou String(). Upravený klient (stále však nepřipravený pro reálný provoz) může vypadat následovně:

package main
 
import (
        "fmt"
        "net"
)
 
func main() {
        conn, err := net.Dial("tcp", "localhost:1234")
        if err != nil {
                println("Connection refused!")
        } else {
                fmt.Printf("Connection established\n")
                fmt.Printf("Remote Address: %s \n", conn.RemoteAddr().String())
                fmt.Printf("Local Address:  %s \n", conn.LocalAddr().String())
 
                var b [1]byte
                n, err := conn.Read(b[:])
                if err != nil {
                        println("No response!")
                } else {
                        if n == 1 {
                                fmt.Printf("Received %d byte: %v\n", n, b)
                        } else {
                                fmt.Printf("Received %d bytes: %v\n", n, b)
                        }
                }
        }
}

Příklad výsledku po připojení klienta k serveru popsanému v další kapitole:

Connection established
Remote Address: 127.0.0.1:1234
Local Address:  127.0.0.1:38082
Received 1 byte: [0]
Poznámka: povšimněte si, že server má pevně zadaný port 1234, zatímco port otevřený na straně klienta je zvolen systémem.

8. Základní kostra serveru komunikujícího přes TCP sokety v jazyku Go

Nyní si ukažme implementaci serveru, na který se budou připojovat výše popsaní klienti. Implementace serveru je nepatrně složitější, než implementace klienta, a to z toho důvodu, že server musí obsloužit větší množství klientů. V tom nejjednodušším případě použijeme takzvaný „neforkující“ server, který bude implementován následujícím způsobem a který dokáže v dané chvíli obsloužit jen jediného klienta:

  1. Použijeme konstruktor nazvaný net.Listen(), v němž opět specifikujeme protokol (viz předchozí kapitolu) a síťové rozhraní s portem 1234.
  2. S využitím příkazu defer zajistíme, že se při ukončení funkce main automaticky uzavře i otevřený port.
  3. Dále v nekonečné smyčce budeme čekat na připojení v metoděAccept. Jakmile se nějaký klient pokusí o připojení, vrátí tato metoda strukturu implementující mj. metody Read a Write. A právě s využitím metody Write pošleme klientovi jediný bajt obsahující hodnotu počitadla dotazů.
  4. Spojení se automaticky ukončí díky použití příkazu defer l.Close()

Zdrojový kód takto napsaného serveru bude vypadat následovně:

package main
 
import (
        "log"
        "net"
)
 
func processRequest(connection net.Conn, cnt *byte) {
        var buffer = []byte{*cnt}
        *cnt++
        n, err := connection.Write(buffer)
        if err != nil {
                log.Println("Writing error", err)
        } else {
                log.Printf("Written %d byte(s)", n)
        }
}
 
func main() {
        cnt := byte(0)
 
        l, err := net.Listen("tcp", "localhost:1234")
        if err != nil {
                log.Fatal("Can't open the port!")
        }
 
        for {
                connection, err := l.Accept()
 
                if err != nil {
                        log.Println("Connection refused!")
                } else {
                        log.Println("Connection accepted")
                        processRequest(connection, &cnt)
                }
        }
}

9. Vylepšení klienta i serveru – uzavírání prostředků a reakce na případné chyby

Jak klient tak i server komunikující s využitím soketů by měly korektně (a navíc i co nejdříve) uzavírat všechny prostředky. Týká se to zejména vlastního připojení, které je v klientovi získáno funkcí net.Dial a uzavřeno by mělo být metodou Close. Vše lze realizovat například v bloku defer:

package main
 
import (
        "log"
        "net"
)
 
const BufferSize = 1024
 
func main() {
        connection, err := net.Dial("tcp", "localhost:1234")
        if err != nil {
                log.Fatal("Connection refused!")
                return
        }
        log.Print("Connection established, waiting for data")
 
        defer func() {
                log.Println("Closing connection")
                err := connection.Close()
                if err != nil {
                        log.Println("Closing connection failed", err)
                }
        }()
 
        buffer := make([]byte, BufferSize)
        n, err := connection.Read(buffer)
        if err != nil {
                log.Println("No response!", err)
        } else {
                if n == 1 {
                        log.Printf("Received %d byte: %v\n", n, buffer[:n])
                } else {
                        log.Printf("Received %d bytes: %v\n", n, buffer[:n])
                }
        }
}

U serveru by se mělo uzavřít jak každé realizované připojení, tak i vlastní síťový server zkonstruovaný funkcí net.Listen. Opět je možné použít bloky defer a výsledek by mohl vypadat následovně:

package main
 
import (
        "log"
        "net"
)
 
func processRequest(connection net.Conn, cnt *byte) {
        defer func() {
                log.Println("Closing connection")
                err := connection.Close()
                if err != nil {
                        log.Println("Closing connection failed", err)
                }
        }()
 
        var buffer = []byte{*cnt}
        *cnt++
        n, err := connection.Write(buffer)
        if err != nil {
                log.Println("Writing error", err)
        } else {
                log.Printf("Written %d byte(s)", n)
        }
}
 
func main() {
        cnt := byte(0)
 
        l, err := net.Listen("tcp", "localhost:1234")
        if err != nil {
                log.Fatal("Can't open the port!")
        }
        defer func() {
                err := l.Close()
                if err != nil {
                        log.Println("Listener close failed", err)
                }
        }()
 
        for {
                connection, err := l.Accept()
 
                if err != nil {
                        log.Println("Connection refused!")
                } else {
                        log.Println("Connection accepted")
                        processRequest(connection, &cnt)
                }
        }
}

10. Simulace zpomalené reakce serveru

Reálné implementace serverů mnohdy provádí nějakou časově náročnou činnost, což znamená, že nedokážou na požadavek klienta odpovědět ihned. Můžeme si tedy otestovat, jak se bude server i klient (popř. klienti) chovat ve chvíli, kdy bude server odpovídat se zpožděním, například se zpožděním dvou sekund. Ono zpoždění bude simulováno pozastavením činnosti serveru po akceptaci připojení klienta:

package main
 
import (
        "log"
        "net"
        "time"
)
 
func processRequest(connection net.Conn, cnt *byte) {
        defer func() {
                log.Println("Closing connection")
                err := connection.Close()
                if err != nil {
                        log.Println("Closing connection failed", err)
                }
        }()
 
        var buffer = []byte{*cnt}
        *cnt++
        n, err := connection.Write(buffer)
        if err != nil {
                log.Println("Writing error", err)
        } else {
                log.Printf("Written %d byte(s)", n)
        }
}
 
func main() {
        cnt := byte(0)
 
        l, err := net.Listen("tcp", "localhost:1234")
        if err != nil {
                log.Fatal("Can't open the port!")
        }
        defer func() {
                err := l.Close()
                if err != nil {
                        log.Println("Listener close failed", err)
                }
        }()
 
        for {
                connection, err := l.Accept()
 
                if err != nil {
                        log.Println("Connection refused!")
                } else {
                        log.Println("Connection accepted")
                        time.Sleep(2 * time.Second)
                        processRequest(connection, &cnt)
                }
        }
}

11. Otestování chování serveru

Nyní server popsaný v předchozí kapitole spustíme v jednom terminálu:

$ go run slow_tcp_server.go

A následně spustíme (částečně souběžně) několik klientů ve druhém terminálu:

$ go run simple_tcp_client.go &
$ go run simple_tcp_client.go &
$ go run simple_tcp_client.go &

Klienti se připojí a budou očekávat odpověď od serveru:

2023/05/02 15:34:16 Connection established, waiting for data
2023/05/02 15:34:16 Connection established, waiting for data
2023/05/02 15:34:17 Connection established, waiting for data
 
2023/05/02 15:34:18 Received 1 byte: [1]
2023/05/02 15:34:18 Closing connection
 
2023/05/02 15:34:20 Received 1 byte: [2]
2023/05/02 15:34:20 Closing connection
 
2023/05/02 15:34:22 Received 1 byte: [3]
2023/05/02 15:34:22 Closing connection

Povšimněte si, že klienti dostávali data postupně (se zpožděním dvou sekund mezi odpověďmi), nikoli současně. To je ostatně patrné i z logů serveru:

2023/05/02 15:34:16 Connection accepted
2023/05/02 15:34:18 Written 1 byte(s)
2023/05/02 15:34:18 Closing connection
2023/05/02 15:34:18 Connection accepted
2023/05/02 15:34:20 Written 1 byte(s)
2023/05/02 15:34:20 Closing connection
2023/05/02 15:34:20 Connection accepted
2023/05/02 15:34:22 Written 1 byte(s)
2023/05/02 15:34:22 Closing connection
Poznámka: v mnoha případech se jedná o chování, které by nebylo pro produkční kód akceptovatelné.

12. Server akceptující větší množství klientů realizovaný pomocí gorutin

Kostra předchozího serveru dokázala obsloužit v daný okamžik pouze jediného klienta. Je tomu tak z toho důvodu, že metoda l.Accept() skončila ve chvíli, kdy se k serveru připojí jediný klient a následně se musí ukončit celé tělo smyčky (v níž je realizováno poslání odpovědi klientovi), aby se l.Accept() zavolala znovu:

for {
        connection, err := l.Accept()
 
        if err != nil {
                log.Println("Connection refused!")
        } else {
                log.Println("Connection accepted")
                processRequest(connection, &cnt)
        }
}

Nic nám ovšem nebrání, aby se vlastní obsluha klienta provedla v gorutině běžící paralelně s hlavní gorutinou. Na obslužnou gorutinu nikde nečekáme, takže se další volání l.Accept() provede velmi rychle:

for {
        connection, err := l.Accept()
 
        if err != nil {
                log.Println("Connection refused!")
        } else {
                log.Println("Connection accepted")
                go processRequest(connection, &cnt)
        }
}
Poznámka: jak uvidíme v dalším textu, nebude chování serveru zcela korektní, protože zvyšování hodnoty proměnné cnt není atomické.

Podívejme se nyní na takto upravený a úplný zdrojový kód demonstračního příkladu:

package main
 
import (
        "log"
        "net"
        "time"
)
 
func processRequest(connection net.Conn, cnt *byte) {
        log.Println("Handling connection")
        time.Sleep(5 * time.Second)
 
        defer func() {
                log.Println("Closing connection")
                err := connection.Close()
                if err != nil {
                        log.Println("Closing connection failed", err)
                }
        }()
 
        var buffer = []byte{*cnt}
        *cnt++
        n, err := connection.Write(buffer)
        if err != nil {
                log.Println("Writing error", err)
        } else {
                log.Printf("Written %d byte(s)", n)
        }
}
 
func main() {
        cnt := byte(0)
 
        l, err := net.Listen("tcp", "localhost:1234")
        if err != nil {
                log.Fatal("Can't open the port!")
        }
        defer func() {
                err := l.Close()
                if err != nil {
                        log.Println("Listener close failed", err)
                }
        }()
 
        for {
                connection, err := l.Accept()
 
                if err != nil {
                        log.Println("Connection refused!")
                } else {
                        log.Println("Connection accepted")
                        go processRequest(connection, &cnt)
                }
        }
}

13. Otestování chování nové implementace serveru

Opět si vyzkoušejme, jak bude server komunikovat s větším množstvím klientů. Nejdříve spustíme v prvním terminálu server:

$ go run multi_connection_tcp_server.go

A poté čtveřici klientů ve druhém terminálu:

$ go run simple_tcp_client.go &
$ go run simple_tcp_client.go &
$ go run simple_tcp_client.go &
$ go run simple_tcp_client.go &

Klienti začnou komunikovat se serverem prakticky okamžitě:

2023/05/02 15:37:27 Connection established, waiting for data
2023/05/02 15:37:28 Connection established, waiting for data
2023/05/02 15:37:28 Connection established, waiting for data
2023/05/02 15:37:28 Connection established, waiting for data

Odpovědi ovšem všichni klienti dostanou až za pět sekund (což je prodleva zakomponovaná do serveru). Všechny odpovědi však jsou připravovány souběžně, každá ve své gorutině:

2023/05/02 15:37:32 Received 1 byte: [0]
2023/05/02 15:37:32 Closing connection
2023/05/02 15:37:33 Received 1 byte: [1]
2023/05/02 15:37:33 Closing connection
2023/05/02 15:37:33 Received 1 byte: [2]
2023/05/02 15:37:33 Closing connection
2023/05/02 15:37:33 Received 1 byte: [3]
2023/05/02 15:37:33 Closing connection

Souběžné zpracování požadavků je ostatně patrné i z logů serveru:

2023/05/02 15:37:27 Connection accepted
2023/05/02 15:37:27 Handling connection
2023/05/02 15:37:28 Connection accepted
2023/05/02 15:37:28 Handling connection
2023/05/02 15:37:28 Connection accepted
2023/05/02 15:37:28 Handling connection
2023/05/02 15:37:28 Connection accepted
2023/05/02 15:37:28 Handling connection
2023/05/02 15:37:32 Written 1 byte(s)
2023/05/02 15:37:32 Closing connection
2023/05/02 15:37:33 Written 1 byte(s)
2023/05/02 15:37:33 Closing connection
2023/05/02 15:37:33 Written 1 byte(s)
2023/05/02 15:37:33 Closing connection
2023/05/02 15:37:33 Written 1 byte(s)
2023/05/02 15:37:33 Closing connection

Nástroj netstat nám vypíše všechny čtyři v daný okamžik připojené klienty:

$ netstat -tunp | grep :1234
 
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp        0      0 127.0.0.1:1234          127.0.0.1:46558         ESTABLISHED 1422106/multi_conne
tcp        0      0 127.0.0.1:54550         127.0.0.1:1234          ESTABLISHED 1422685/simple_tcp_
tcp        0      0 127.0.0.1:46558         127.0.0.1:1234          ESTABLISHED 1422959/simple_tcp_
tcp        0      0 127.0.0.1:1234          127.0.0.1:54550         ESTABLISHED 1422106/multi_conne
tcp        0      0 127.0.0.1:1234          127.0.0.1:54564         ESTABLISHED 1422106/multi_conne
tcp        0      0 127.0.0.1:1234          127.0.0.1:46566         ESTABLISHED 1422106/multi_conne
tcp        0      0 127.0.0.1:46566         127.0.0.1:1234          ESTABLISHED 1423115/simple_tcp_
tcp        0      0 127.0.0.1:54564         127.0.0.1:1234          ESTABLISHED 1422821/simple_tcp_

14. Klient komunikující přes unixové sokety naprogramovaný v Go

Nyní si ukažme, jak by mohl vypadat klient, který se serverem komunikuje nikoli přes TCP či UDP (tedy přes reálné nebo lokální síťové rozhraní), ale přes standardní unixové sokety. Samotný kód klienta se nebude příliš odlišovat od TCP klienta, v němž bylo spojení „vytočeno“ funkcí:

conn, err := net.Dial("tcp", "localhost:1234")

nyní použijeme tutéž funkci, ale s odlišnými parametry:

connection, err := net.Dial("unix", SocketFileName)

kde SocktFileName je jméno speciálního souboru představujícího soket:

const SocketFileName = "/tmp/xyzzy"

Úplný kód klienta by mohl vypadat následovně:

package main
 
import (
        "log"
        "net"
)
 
const BufferSize = 1024
const SocketFileName = "/tmp/xyzzy"
 
func main() {
        connection, err := net.Dial("unix", SocketFileName)
        if err != nil {
                log.Fatal("Connection refused!")
                return
        }
        log.Print("Connection established, waiting for data")
 
        defer func() {
                log.Println("Closing connection")
                err := connection.Close()
                if err != nil {
                        log.Println("Closing connection failed", err)
                }
        }()
 
        buffer := make([]byte, BufferSize)
        n, err := connection.Read(buffer)
        if err != nil {
                log.Println("No response!", err)
        } else {
                if n == 1 {
                        log.Printf("Received %d byte: %v\n", n, buffer[:n])
                } else {
                        log.Printf("Received %d bytes: %v\n", n, buffer[:n])
                }
        }
}

15. Server komunikující přes unixové sokety naprogramovaný v Go

Ani zdrojový kód serveru určeného pro komunikaci přes unixové sokety se nebude příliš odlišovat od serveru, jenž komunikoval přes TCP či UDP. Ostatně podívejme se na následující zdrojový kód, z něhož je patrné, že se liší pouze parametry předávané do funkce net.Listen:

package main
 
import (
        "log"
        "net"
)
 
const SocketFileName = "/tmp/xyzzy"
 
func processRequest(connection net.Conn, cnt *byte) {
        defer func() {
                log.Println("Closing connection")
                err := connection.Close()
                if err != nil {
                        log.Println("Closing connection failed", err)
                }
        }()
 
        var buffer = []byte{*cnt}
        *cnt++
        n, err := connection.Write(buffer)
        if err != nil {
                log.Println("Writing error", err)
        } else {
                log.Printf("Written %d byte(s)", n)
        }
}
 
func main() {
        cnt := byte(0)
 
        l, err := net.Listen("unix", SocketFileName)
        if err != nil {
                log.Fatal("Can't open the port!")
        }
        defer func() {
                err := l.Close()
                if err != nil {
                        log.Println("Listener close failed", err)
                }
        }()
 
        for {
                connection, err := l.Accept()
 
                if err != nil {
                        log.Println("Connection refused!")
                } else {
                        log.Println("Connection accepted")
                        processRequest(connection, &cnt)
                }
        }
}

Praktičtější je však server, jenž může v daný okamžik komunikovat s větším množstvím klientů. Opět zde s výhodou využijeme gorutiny:

package main
 
import (
        "log"
        "net"
        "time"
)
 
const SocketFileName = "/tmp/xyzzy"
 
func processRequest(connection net.Conn, cnt *byte) {
        log.Println("Handling connection")
        time.Sleep(10 * time.Second)
 
        defer func() {
                log.Println("Closing connection")
                err := connection.Close()
                if err != nil {
                        log.Println("Closing connection failed", err)
                }
        }()
 
        var buffer = []byte{*cnt}
        *cnt++
        n, err := connection.Write(buffer)
        if err != nil {
                log.Println("Writing error", err)
        } else {
                log.Printf("Written %d byte(s)", n)
        }
}
 
func main() {
        cnt := byte(0)
 
        l, err := net.Listen("unix", SocketFileName)
        if err != nil {
                log.Fatal("Can't open the port!")
        }
        defer func() {
                err := l.Close()
                if err != nil {
                        log.Println("Listener close failed", err)
                }
        }()
 
        for {
                connection, err := l.Accept()
 
                if err != nil {
                        log.Println("Connection refused!")
                } else {
                        log.Println("Connection accepted")
                        go processRequest(connection, &cnt)
                }
        }
}

17. Otestování komunikace mezi klientem a serverem

Opět si otestujme, jak bude se serverem současně komunikovat větší množství klientů. Nejprve spustíme server, který by měl nabídnout komunikaci přes unixové sokety:

$ go run multi_connection_unix_socket_server.go

Dále ve druhém terminálu spustíme (prakticky v totožný okamžik) větší množství klientů:

$ go run simple_unix_socket_client.go &
$ go run simple_unix_socket_client.go &
$ go run simple_unix_socket_client.go &
$ go run simple_unix_socket_client.go &
$ go run simple_unix_socket_client.go &

Celá pětice klientů by měla ihned vypsat, že bylo navázáno spojení se serverem:

2023/05/02 16:09:21 Connection established, waiting for data
2023/05/02 16:09:21 Connection established, waiting for data
2023/05/02 16:09:22 Connection established, waiting for data
2023/05/02 16:09:22 Connection established, waiting for data
2023/05/02 16:09:22 Connection established, waiting for data

I server by měl na terminál vypsat, že se vytvořila pětice připojení:

2023/05/02 16:09:21 Connection accepted
2023/05/02 16:09:21 Handling connection
2023/05/02 16:09:21 Connection accepted
2023/05/02 16:09:21 Handling connection
2023/05/02 16:09:22 Connection accepted
2023/05/02 16:09:22 Handling connection
2023/05/02 16:09:22 Connection accepted
2023/05/02 16:09:22 Handling connection
2023/05/02 16:09:22 Connection accepted
2023/05/02 16:09:22 Handling connection

Po deseti sekundách server začne odpovídat:

2023/05/02 16:09:31 Written 1 byte(s)
2023/05/02 16:09:31 Closing connection
2023/05/02 16:09:31 Written 1 byte(s)
2023/05/02 16:09:31 Closing connection
2023/05/02 16:09:32 Written 1 byte(s)
2023/05/02 16:09:32 Closing connection
2023/05/02 16:09:32 Written 1 byte(s)
2023/05/02 16:09:32 Closing connection
2023/05/02 16:09:32 Written 1 byte(s)
2023/05/02 16:09:32 Closing connection

A právě po oněch po cca deseti sekundách by skutečně měla být (a to pochopitelně každým klientem zvlášť) přijata odpověď:

2023/05/02 16:09:31 Received 1 byte: [0]
2023/05/02 16:09:31 Closing connection
2023/05/02 16:09:31 Received 1 byte: [1]
2023/05/02 16:09:31 Closing connection
2023/05/02 16:09:32 Received 1 byte: [2]
2023/05/02 16:09:32 Closing connection
2023/05/02 16:09:32 Received 1 byte: [3]
2023/05/02 16:09:32 Closing connection
2023/05/02 16:09:32 Received 1 byte: [4]
2023/05/02 16:09:32 Closing connection

V průběhu komunikace (ještě předtím, než jsou jednotlivá spojení uzavřena) by měl nástroj lsof vypsat informace o celé pětici navázaných spojení:

ict ve školství 24

$ lsof /tmp/xyzzy
 
COMMAND       PID     USER   FD   TYPE             DEVICE SIZE/OFF       NODE NAME
multi_con 1452207 ptisnovs    3u  unix 0x0000000000000000      0t0 2944390322 /tmp/xyzzy type=STREAM
multi_con 1452207 ptisnovs    4u  unix 0x0000000000000000      0t0 2944390332 /tmp/xyzzy type=STREAM
multi_con 1452207 ptisnovs   11u  unix 0x0000000000000000      0t0 2944381541 /tmp/xyzzy type=STREAM
multi_con 1452207 ptisnovs   12u  unix 0x0000000000000000      0t0 2944381558 /tmp/xyzzy type=STREAM
multi_con 1452207 ptisnovs   13u  unix 0x0000000000000000      0t0 2944393280 /tmp/xyzzy type=STREAM

18. Obsah druhé části článku

V navazujícím článku si ukážeme, jak lze využít komunikaci přes unixové sokety prakticky. Konkrétně bude představen způsob konfigurace oblíbeného nástroje HAProxy právě přes unixové sokety. Povšimněte si, o jak vhodnou volbu se v tomto případě ze strany autorů HAProxy jedná – u Unixových soketů je zaručeno, že komunikace bude navázána pouze lokálně; není tudíž nutné konfigurovat firewall. Navíc při přístupu k souboru představujícího soket můžeme využít klasická přístupová práva Unixu (Linuxu); opět bez nutnosti použití nějakého dalšího nástroje.

19. Repositář s demonstračními příklady

Zdrojové kódy všech dnes použitých demonstračních příkladů naprogramovaných v jazyku Go byly uloženy do Git repositáře, který je dostupný na adrese https://github.com/tisnik/go-root. V případě, že nebudete chtít klonovat celý repositář, můžete namísto toho použít odkazy na jednotlivé demonstrační příklady, které naleznete v následující tabulce:

# Příklad/soubor Stručný popis Cesta
1 simple_tcp_client jednoduchý klient komunikující se serverem s využitím protokolu TCP https://github.com/tisnik/go-root/blob/master/article_A7/sim­ple_tcp_client/
2 simple_tcp_server jednoduchý server naslouchající na TCP portu a komunikující přes protokol TCP https://github.com/tisnik/go-root/blob/master/article_A7/sim­ple_tcp_server/
3 slow_tcp_server TCP server naprogramovaný v Go, který odpovídá se zpožděním https://github.com/tisnik/go-root/blob/master/article_A7/slow_tcp_ser­ver/
4 multi_connection_tcp_server TCP server pro větší množství klientů https://github.com/tisnik/go-root/blob/master/article_A7/mul­ti_connection_tcp_server/
5 simple_unix_socket_client klient používající unixové sokety https://github.com/tisnik/go-root/blob/master/article_A7/sim­ple_unix_socket_client/
6 simple_unix_socket_server server používající unixové sokety https://github.com/tisnik/go-root/blob/master/article_A7/sim­ple_unix_socket_server/
7 haproxy_controller ukázka ovládání HAProxy přes sokety https://github.com/tisnik/go-root/blob/master/article_A7/ha­proxy_controller/

20. Odkazy na Internetu

  1. What Are Unix Sockets and How Do They Work?
    https://www.howtogeek.com/devops/what-are-unix-sockets-and-how-do-they-work/
  2. Unix domain socket
    https://en.wikipedia.org/wi­ki/Unix_domain_socket
  3. Interprocess Communication With Unix Sockets
    https://www.baeldung.com/li­nux/communicate-with-unix-sockets
  4. Creating A Simple Web Server With Golang
    https://tutorialedge.net/go­lang/creating-simple-web-server-with-golang/
  5. Building a Web Server in Go
    https://thenewstack.io/building-a-web-server-in-go/
  6. How big is the pipe buffer?
    https://unix.stackexchange­.com/questions/11946/how-big-is-the-pipe-buffer
  7. How to turn off buffering of stdout in C
    https://stackoverflow.com/qu­estions/7876660/how-to-turn-off-buffering-of-stdout-in-c
  8. OSI model
    https://en.wikipedia.org/wi­ki/OSI_model
  9. Datagram
    https://cs.wikipedia.org/wi­ki/Datagram
  10. Softwarová rozhraní systémů UNIX pro přístup k síťovým službám
    https://www.cs.vsb.cz/gry­garek/PS/sockets.html
  11. HAProxy: Management Guide version 2.4.22–1
    https://docs.haproxy.org/2­.4/management.html#9.3
  12. Síťový socket
    https://cs.wikipedia.org/wi­ki/S%C3%AD%C5%A5ov%C3%BD_soc­ket
  13. Unix domain socket
    https://cs.wikipedia.org/wi­ki/Unix_domain_socket
  14. unix domain sockets vs. internet sockets
    https://lists.freebsd.org/pi­permail/freebsd-performance/2005-February/001143.html
  15. What's the difference between streams and datagrams in network programming?
    https://stackoverflow.com/qu­estions/4688855/whats-the-difference-between-streams-and-datagrams-in-network-programming
  16. Introducing TCP/IP concepts: Selecting sockets
    https://www.ibm.com/docs/en/zos/2­.3.0?topic=concepts-introducing-tcpip-selecting-sockets
  17. Windows Sockets: Sokety datového proudu
    https://learn.microsoft.com/cs-cz/cpp/mfc/windows-sockets-stream-sockets?view=msvc-170
  18. Windows Sockets: Sokety datagramů
    https://learn.microsoft.com/cs-cz/cpp/mfc/windows-sockets-datagram-sockets?view=msvc-170
  19. Berkeley sockets
    https://cs.wikipedia.org/wi­ki/Berkeley_sockets
  20. Sokety a C/C++: datagramy a PF_UNIX
    https://www.root.cz/clanky/sokety-a-c-datagramy-a-pf-unix/

Autor článku

Vystudoval VUT FIT a v současné době pracuje na projektech vytvářených v jazycích Python a Go.