Obsah
1. Užitečná novinka v Go 1.22: vylepšení směrování v knihovně net/http
2. Jednoduchý HTTP server využívající rozhraní ResponseWriter a strukturu Request
3. HTTP server využívající knihovnu gorilla/mux
4. Specifikace HTTP metody pro jednotlivé handlery
5. Kostra webové služby pro správu osob
6. Nové možnosti při směrování dotazů v Go 1.22
7. Webová služba umožňující manipulace se seznamem uživatelů
8. Realizace handleru pro výpis seznamu uživatelů
9. Úplný zdrojový kód první verze webové služby
11. Úplný zdrojový kód druhé verze webové služby
12. Otestování chování upravené varianty webové služby
13. Kontrola, zda je při volání endpointu použita očekávaná metoda
14. Úplný zdrojový kód třetí verze webové služby
15. Otestování, jak webová služba kontroluje HTTP metody požadavků
16. Nedostatky webové služby a jejich náprava v Go 1.22
17. Úplný zdrojový kód čtvrté verze webové služby
18. Otestování základních funkcí poslední varianty webové služby
19. Repositář s demonstračními příklady
1. Užitečná novinka v Go 1.22: vylepšení směrování v knihovně net/http
Programovací jazyk Go je mezi vývojáři oblíbený mj. i z toho důvodu, že jeho standardní knihovny obsahují množství rozhraní, datových struktur a funkcí, které lze přímo využít při tvorbě aplikací pracujících s různými síťovými protokoly. Nalezneme zde například knihovnu net/http určenou pro komunikaci s využitím známého HTTP protokolu. Standardní knihovna net/http umožňuje tvořit jak HTTP klienty, tak i servery.
Ovšem právě v oblasti HTTP serverů standardní knihovně jazyka Go „něco“ chybělo k tomu, aby ji bylo možné samostatně, plnohodnotně a bezproblémově využít. Jednalo se o nedostatečné možnosti při směrování požadavků (tedy pro mapování mezi adresami koncových bodů a handlery, které zpracovávají příslušné požadavky) a taktéž o problematické určení, které HTTP metody je možné pro dané koncové body využít. To se změnilo v Go verze 1.22, kde došlo ke dvěma vylepšením právě v těchto oblastech. Od Go 1.22 se tedy můžeme (alespoň v některých situacích) obejít bez knihoven třetích stran, například bez knihovny Gorilla/mux atd.
2. Jednoduchý HTTP server využívající rozhraní ResponseWriter a strukturu Request
Nejprve si připomeňme, jakým způsobem je vlastně možné s využitím výše uvedeného standardního balíčku net/http vytvořit jednoduchý HTTP server. Pravděpodobně nejjednodušší podoba takového serveru má zaregistrován pouze jediný handler, tedy funkci, která je volána při zadání adresy localhost:8080 nebo localhost:8080/ (nebo v tomto případě prakticky jakéhokoli jiného koncového bodu). Odpověď serveru bude vždy stejná – HTTP 200 OK a zpráva bude znít „Hello world!“:
package main import ( "io" "net/http" ) func mainEndpoint(writer http.ResponseWriter, request *http.Request) { io.WriteString(writer, "Hello world!\n") } func main() { http.HandleFunc("/", mainEndpoint) http.ListenAndServe(":8000", nil) }
Zajímavější a vlastně i mnohem užitečnější je ovšem pochopitelně implementace HTTP serveru, která bude generovat dynamický obsah. Ten lze tvořit buď přímo „ručně“ v programu, nebo můžeme využít některé balíčky ze standardní knihovny programovacího jazyka Go pro generování dat ve formátu JSON, XML, popř. knihovny s implementací šablon (templates). Dnes nás ovšem bude primárně zajímat první způsob, tj. „ruční“ generování odpovědi, která je serverem posílána klientovi na základě jeho dotazu (request). Jedna z nejjednodušších implementací takového HTTP serveru může vypadat například následovně:
package main import ( "net/http" ) func mainEndpoint(writer http.ResponseWriter, request *http.Request) { response := "Hello world!\n" writer.Write([]byte(response)) } func main() { http.HandleFunc("/", mainEndpoint) http.ListenAndServe(":8000", nil) }
Funkci takového serveru si můžeme snadno otestovat, například s využitím nástroje wget nebo ještě lépe curl. Vzhledem k tomu, že víme, na jakém portu server běží a jaký endpoint máme zavolat, sestavíme příkaz pro curl tímto způsobem:
$ curl -v localhost:8000
Výstup bude obsahovat i ladicí informace vyžádané přepínačem -v:
* Rebuilt URL to: localhost:8000/ * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8000 (#0) > GET / HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8000 > Accept: */* > < HTTP/1.1 200 OK < Date: Mon, 06 May 2019 18:14:06 GMT < Content-Length: 13 < Content-Type: text/plain; charset=utf-8 < Hello world! * Connection #0 to host localhost left intact
Ve zdrojovém kódu si povšimněte především funkce, v níž je implementováno generování a posílání odpovědi. Této funkci jsou předány dvě hodnoty, přičemž první je typu rozhraní http.ResponseWriter a druhá je typu ukazatel na http.Request:
type Request struct { Method string URL *url.URL Proto string // "HTTP/1.0" ProtoMajor int // 1 ProtoMinor int // 0 Header Header Body io.ReadCloser GetBody func() (io.ReadCloser, error) ContentLength int64 TransferEncoding []string Close bool Host string Form url.Values PostForm url.Values MultipartForm *multipart.Form Trailer Header RemoteAddr string RequestURI string TLS *tls.ConnectionState Cancel <-chan struct{} Response *Response Pattern string }
a:
type ResponseWriter interface { Header() Header Write([]byte) (int, error) WriteHeader(statusCode int) }
3. HTTP server využívající knihovnu gorilla/mux
Nyní se podívejme na způsob realizace jednoduchého HTTP serveru, který bude mj. používat i (nestandardní) balíček gorilla/mux pocházející z Gorilla Toolkitu. Základní služby poskytované serverem budou stejné, ovšem navíc budeme implementovat koncový bod, který vrátí obsah čítače (interně se zvyšuje při každém přístupu). Požadované změny nastanou náhradou následujících dvou řádků s registrací handlerů, které bychom použili v net/http:
http.HandleFunc("/", mainEndpoint) http.HandleFunc("/counter", counterEndpoint)
V upraveném zdrojovém kódu demonstračního příkladu použijeme takzvaný směrovač neboli router poskytovaný právě knihovnou gorilla/mux. Jeho konstrukce může vypadat následovně:
router := mux.NewRouter()
Popř. můžeme explicitně specifikovat, zda se budou URI typu /cesta a /cesta/ považovat za shodné či nikoli:
router := mux.NewRouter().StrictSlash(true)
Dále zaregistrujeme oba výše zmíněné handlery, ovšem nyní použijeme metodu router.HandleFunc a nikoli funkci http.HandleFunc (z balíčku net/http):
router.HandleFunc("/", mainEndpoint) router.HandleFunc("/counter", counterEndpoint)
Nakonec je pochopitelně nutné náš nově upravený HTTP server spustit. Povšimněte si, že se v tomto případě využije druhý parametr funkce http.ListenAndServe – již se zde nepředává hodnota nil, ale instance právě nakonfigurovaného směrovače:
err := http.ListenAndServe(ADDRESS, router)
Úplný zdrojový kód upraveného příkladu, který naleznete na adrese https://github.com/tisnik/go-root/blob/master/article38/02_http_server_with_mux.go, vypadá následovně:
package main import ( "fmt" "github.com/gorilla/mux" "io" "log" "net/http" "os" "sync" ) const ADDRESS = ":8080" var counter int var mutex = &sync.Mutex{} func mainEndpoint(writer http.ResponseWriter, request *http.Request) { io.WriteString(writer, "Hello world!\n") } func counterEndpoint(writer http.ResponseWriter, request *http.Request) { mutex.Lock() counter++ fmt.Fprintf(writer, "Counter: %d\n", counter) mutex.Unlock() } func main() { router := mux.NewRouter().StrictSlash(true) router.HandleFunc("/", mainEndpoint) router.HandleFunc("/counter", counterEndpoint) log.Println("Starting HTTP server at address", ADDRESS) err := http.ListenAndServe(ADDRESS, router) if err != nil { log.Fatal("Unable to initialize HTTP server", err) os.Exit(2) } }
Funkcionalitu tohoto příkladu snadno otestujeme, a to opět s využitím nástroje curl:
$ curl -v localhost:8080 * Rebuilt URL to: localhost:8080/ * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET / HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8080 > Accept: */* > < HTTP/1.1 200 OK < Date: Sun, 13 Oct 2019 16:25:33 GMT < Content-Length: 13 < Content-Type: text/plain; charset=utf-8 < Hello world! * Connection #0 to host localhost left intact
Otestování funkce čítače:
$ curl -v localhost:8080/counter * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET /counter HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8080 > Accept: */* > < HTTP/1.1 200 OK < Date: Sun, 13 Oct 2019 16:25:48 GMT < Content-Length: 11 < Content-Type: text/plain; charset=utf-8 < Counter: 1 * Connection #0 to host localhost left intact
4. Specifikace HTTP metody pro jednotlivé handlery
V případě, že při volání demonstračního příkladu z předchozí kapitoly použijeme jinou HTTP metodu než GET (což je pro nástroj curl výchozí metoda, pokud ovšem nebudeme na server posílat data), bude například čítač stále přístupný. O tom se ostatně můžeme velmi snadno přesvědčit, pokud budeme explicitně specifikovat metodu POST, PUT či dokonce DELETE:
$ curl -v -X POST localhost:8080/counter * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8080 (#0) > POST /counter HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8080 > Accept: */* > < HTTP/1.1 200 OK < Date: Sun, 13 Oct 2019 16:31:50 GMT < Content-Length: 11 < Content-Type: text/plain; charset=utf-8 < Counter: 2 * Connection #0 to host localhost left intact $ curl -v -X DELETE localhost:8080/counter * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8080 (#0) > DELETE /counter HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8080 > Accept: */* > < HTTP/1.1 200 OK < Date: Sun, 13 Oct 2019 16:31:56 GMT < Content-Length: 11 < Content-Type: text/plain; charset=utf-8 < Counter: 3 * Connection #0 to host localhost left intact
Takové chování ovšem většinou u služeb postavených nad REST API není ideální, protože s prostředky, které jsou přes API obsluhovány, se provádí různé operace typu CRUD. Samozřejmě je možné i při použití základního balíčku net/http získat jméno použité metody, ovšem nejedná se o ideální řešení. To nám nabízí již zmíněný balíček gorilla/mux, v němž můžeme omezit volání handleru pouze pro danou metodu. V našem demonstračním příkladu prozatím pouze čteme hodnoty (prostředků) a neměníme je, takže nám postačuje použít metodu GET omezit použití ostatních metod:
router := mux.NewRouter().StrictSlash(true) router.HandleFunc("/", mainEndpoint).Methods("GET") router.HandleFunc("/counter", counterEndpoint).Methods("GET")
Upravený zdrojový kód demonstračního příkladu bude vypadat následovně:
package main import ( "fmt" "github.com/gorilla/mux" "io" "log" "net/http" "os" "sync" ) const ADDRESS = ":8080" var counter int var mutex = &sync.Mutex{} func mainEndpoint(writer http.ResponseWriter, request *http.Request) { io.WriteString(writer, "Hello world!\n") } func counterEndpoint(writer http.ResponseWriter, request *http.Request) { mutex.Lock() counter++ fmt.Fprintf(writer, "Counter: %d\n", counter) mutex.Unlock() } func main() { router := mux.NewRouter().StrictSlash(true) router.HandleFunc("/", mainEndpoint).Methods("GET") router.HandleFunc("/counter", counterEndpoint).Methods("GET") log.Println("Starting HTTP server at address", ADDRESS) err := http.ListenAndServe(ADDRESS, router) if err != nil { log.Fatal("Unable to initialize HTTP server", err) os.Exit(2) } }
Můžeme si ihned otestovat, jak se bude nová služba chovat při použití různých HTTP metod.
Výchozí metoda GET:
$ curl -v localhost:8080/ * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET / HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8080 > Accept: */* > < HTTP/1.1 200 OK < Date: Sun, 13 Oct 2019 18:45:33 GMT < Content-Length: 13 < Content-Type: text/plain; charset=utf-8 < Hello world!
Metoda PUT:
$ curl -v -X PUT localhost:8080/ * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8080 (#0) > PUT / HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8080 > Accept: */* > < HTTP/1.1 405 Method Not Allowed < Date: Sun, 13 Oct 2019 18:45:37 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
Metoda POST:
$ curl -v -X POST localhost:8080/ * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8080 (#0) > POST / HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8080 > Accept: */* > < HTTP/1.1 405 Method Not Allowed < Date: Sun, 13 Oct 2019 18:45:42 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
Metoda DELETE:
$ curl -v -X DELETE localhost:8080/ * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to localhost (127.0.0.1) port 8080 (#0) > DELETE / HTTP/1.1 > User-Agent: curl/7.35.0 > Host: localhost:8080 > Accept: */* > < HTTP/1.1 405 Method Not Allowed < Date: Sun, 13 Oct 2019 18:45:45 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
5. Kostra webové služby pro správu osob
Ukažme si ještě, jak by mohla vypadat kostra webové služby pro správu osob. K dispozici budou tyto operace:
# | Operace | Volání | Metoda |
---|---|---|---|
1 | výpis celé databáze | /users | GET |
2 | informace o zvolené osobě | /user/ID_OSOBY | GET |
3 | přidání nové osoby do databáze | /user/ID_OSOBY | POST |
4 | změna údajů v databázi | /user/ID_OSOBY | PUT |
5 | vymazání osoby | /user/ID_OSOBY | DELETE |
Nejprve použijeme Gorilla/mux. Při specifikaci handlerů využijeme toho, že (proměnné) jméno prostředku lze uzavřít do složených závorek:
router.HandleFunc("/user", listAllUsersEndpoint).Methods("GET") router.HandleFunc("/user/{id}", getUserEndpoint).Methods("GET") router.HandleFunc("/user/{id}", createUserEndpoint).Methods("POST") router.HandleFunc("/user/{id}", updateUserEndpoint).Methods("PUT") router.HandleFunc("/user/{id}", deleteUserEndpoint).Methods("DELETE")
Kostra této služby, pro stručnost bez implementace jednotlivých operací v handlerech, může vypadat následovně:
package main import ( "github.com/gorilla/mux" "io" "log" "net/http" "os" ) const ADDRESS = ":8080" func mainEndpoint(writer http.ResponseWriter, request *http.Request) { io.WriteString(writer, "Hello world!\n") } func listAllUsersEndpoint(writer http.ResponseWriter, request *http.Request) { io.WriteString(writer, "LIST ALL PERSONS\n") } func getUserEndpoint(writer http.ResponseWriter, request *http.Request) { id := mux.Vars(request)["id"] person, found := persons[id] ... ... ... io.WriteString(writer, "GET PERSON\n") } func createUserEndpoint(writer http.ResponseWriter, request *http.Request) { id := mux.Vars(request)["id"] person, found := persons[id] ... ... ... io.WriteString(writer, "CREATE PERSON\n") } func updateUserEndpoint(writer http.ResponseWriter, request *http.Request) { id := mux.Vars(request)["id"] person, found := persons[id] ... ... ... io.WriteString(writer, "UPDATE PERSON\n") } func deleteUserEndpoint(writer http.ResponseWriter, request *http.Request) { id := mux.Vars(request)["id"] person, found := persons[id] ... ... ... io.WriteString(writer, "DELETE PERSON\n") } func main() { router := mux.NewRouter().StrictSlash(true) router.HandleFunc("/", mainEndpoint).Methods("GET") router.HandleFunc("/person", listAllUsersEndpoint).Methods("GET") router.HandleFunc("/person/{id}", getUserEndpoint).Methods("GET") router.HandleFunc("/person/{id}", createUserEndpoint).Methods("POST") router.HandleFunc("/person/{id}", updateUserEndpoint).Methods("PUT") router.HandleFunc("/person/{id}", deleteUserEndpoint).Methods("DELETE") log.Println("Starting HTTP server at address", ADDRESS) err := http.ListenAndServe(ADDRESS, router) if err != nil { log.Fatal("Unable to initialize HTTP server", err) os.Exit(2) } }
6. Nové možnosti při směrování dotazů v Go 1.22
V základním balíčku programovacího jazyka Go, konkrétně v balíčku net/http, se rozšířily možnosti specifikace vazby mezi adresou endpointu a příslušným handlerem. Nově je možné přímo při registraci handleru definovat, která HTTP metoda se bude používat. A navíc je možné, aby se při specifikaci endpointu určily i proměnné části, což jsou typicky identifikátory zdrojů (resources), například ID uživatelů atd. Tyto proměnné části jsou nejenom korektně rozpoznány, ale navíc je možné snadno získat i jejich konkrétní hodnoty, tedy například o jaké konkrétní ID uživatele se jedná.
To ovšem znamená, že vlastně byla implementována nejdůležitější funkcionalita z Gorilla/mux. To poměrně dobře odpovídá filozofii jazyka Go, který se snaží minimálně v oblasti síťových aplikací nabízet již ve standardní knihovně většinu potřebné funkcionality, čímž se omezí nutnost instalace a importu dalších knihoven (čemuž se ovšem obecně stejně nedokážeme vyhnout, což má většinou i nepříjemné důsledky v praxi).
7. Webová služba umožňující manipulace se seznamem uživatelů
V rámci navazujících kapitol si ukážeme realizaci webové služby určené pro správu osob, resp. přesněji řečeno její značně zjednodušenou variantu, kterou je ovšem možné snadno rozšířit o chybějící funkce. Informace o jedné osobě je tvořena touto datovou strukturou:
// Datova struktura predstavujici uzivatele type User struct { Name string Surname string }
Pro práci s databází osob nám bude stačit několik metod, které jsou předepsány v rozhraní UserStorage:
// Rozhrani s predpisem metod pro manipulace s databazi uzivatelu type UserStorage interface { ReadListOfUsers() []User ReadUser(ID int) (User, bool) DeleteUser(ID int) }
Vlastní implementace databáze osob bude velmi jednoduchá, protože namísto reálné relační či objektové databáze použijeme seznam osob uložených jen v operační paměti. Každá osoba bude uložena do mapy obsahující následující datové struktury (klíčem bude ID):
// Implementace databaze uzivatelu v operacni pameti type MemoryStorage struct { users map[int]User } // Inicializace databaze uzivatelu func NewMemoryStorage() MemoryStorage { users := make(map[int]User) users[1] = User{ Name: "Linus", Surname: "Torvalds", } users[2] = User{ Name: "Rob", Surname: "Pike", } users[3] = User{ Name: "Ken", Surname: "Iverson", } return MemoryStorage{ users: users, } } // Implementace vsech metod predepsanych rozhranim UserStorage func (s MemoryStorage) ReadListOfUsers() []User { users := make([]User, 0, len(s.users)) for _, user := range s.users { users = append(users, user) } return users } func (s MemoryStorage) ReadUser(ID int) (User, bool) { user, found := s.users[ID] return user, found } func (s MemoryStorage) DeleteUser(ID int) { delete(s.users, ID) }
8. Realizace handleru pro výpis seznamu uživatelů
Podívejme se nyní na to, jak by mohla vypadat realizace handleru, který bude zavolán při přístupu na koncový bod /users. V takovém případě by se měl vrátit JSON se všemi uživateli v databázi. Registrace příslušného handleru je následující:
// REST API endpoints http.HandleFunc("/users", s.returnListOfUsers)
Samotný handler nejprve přečte informace o všech uživatelích uložených v databázi a následně seznam serializuje do JSONu, který je poslán zpět klientovi. Vše lze tedy realizovat jen na několika řádcích zdrojového kódu (ovšem bez kontroly chyb!):
// REST API handlery func (s ServerImpl) returnListOfUsers(writer http.ResponseWriter, r *http.Request) { users := s.storage.ReadListOfUsers() writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(users) }
9. Úplný zdrojový kód první verze webové služby
Následuje výpis celého zdrojového kódu první verze naší webové služby:
package main import ( "encoding/json" "fmt" "log" "net/http" ) // Datova struktura predstavujici uzivatele type User struct { Name string Surname string } // Rozhrani s predpisem metod pro manipulace s databazi uzivatelu type UserStorage interface { ReadListOfUsers() []User ReadUser(ID int) (User, bool) DeleteUser(ID int) } // Implementace databaze uzivatelu v operacni pameti type MemoryStorage struct { users map[int]User } // Inicializace databaze uzivatelu func NewMemoryStorage() MemoryStorage { users := make(map[int]User) users[1] = User{ Name: "Linus", Surname: "Torvalds", } users[2] = User{ Name: "Rob", Surname: "Pike", } users[3] = User{ Name: "Ken", Surname: "Iverson", } return MemoryStorage{ users: users, } } // Implementace vsech metod predepsanych rozhranim UserStorage func (s MemoryStorage) ReadListOfUsers() []User { users := make([]User, 0, len(s.users)) for _, user := range s.users { users = append(users, user) } return users } func (s MemoryStorage) ReadUser(ID int) (User, bool) { user, found := s.users[ID] return user, found } func (s MemoryStorage) DeleteUser(ID int) { delete(s.users, ID) } // Rozhrani predepisujici metody serveru type Server interface { Serve(port uint) } // Implementace HTTPServeru type ServerImpl struct { storage UserStorage } // Inicialiace serveru, predani kontextu func NewServer(storage UserStorage) Server { return ServerImpl{ storage: storage, } } func (s ServerImpl) Serve(port uint) { log.Printf("Starting server on port 8080") // REST API endpoints http.HandleFunc("/users", s.returnListOfUsers) // start the server http.ListenAndServe(fmt.Sprintf(":%d", port), nil) } // REST API handlery func (s ServerImpl) returnListOfUsers(writer http.ResponseWriter, r *http.Request) { users := s.storage.ReadListOfUsers() writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(users) } func main() { storage := NewMemoryStorage() server := NewServer(storage) server.Serve(8080) }
10. Otestování webové služby
Otestování této primitivní webové služby je snadné – postačuje nám použít nástroj curl:
$ curl localhost:8080/users | jq .
S výsledkem:
[ { "Name": "Linus", "Surname": "Torvalds" }, { "Name": "Rob", "Surname": "Pike" }, { "Name": "Ken", "Surname": "Iverson" } ]
11. Úplný zdrojový kód druhé verze webové služby
Druhá varianta webové služby obsahující handlery určené pro výpis konkrétního uživatele, popř. pro smazání uživatele identifikovaného svým ID, vypadá následovně. Povšimněte si určitých problémů, například opakujícího se kódu, použití „sloves“ v koncových bodech atd.:
package main import ( "encoding/json" "fmt" "log" "net/http" "strconv" ) // Datova struktura predstavujici uzivatele type User struct { Name string Surname string } // Rozhrani s predpisem metod pro manipulace s databazi uzivatelu type UserStorage interface { ReadListOfUsers() []User ReadUser(ID int) (User, bool) DeleteUser(ID int) } // Implementace databaze uzivatelu v operacni pameti type MemoryStorage struct { users map[int]User } // Inicializace databaze uzivatelu func NewMemoryStorage() MemoryStorage { users := make(map[int]User) users[1] = User{ Name: "Linus", Surname: "Torvalds", } users[2] = User{ Name: "Rob", Surname: "Pike", } users[3] = User{ Name: "Ken", Surname: "Iverson", } return MemoryStorage{ users: users, } } // Implementace vsech metod predepsanych rozhranim UserStorage func (s MemoryStorage) ReadListOfUsers() []User { users := make([]User, 0, len(s.users)) for _, user := range s.users { users = append(users, user) } return users } func (s MemoryStorage) ReadUser(ID int) (User, bool) { user, found := s.users[ID] return user, found } func (s MemoryStorage) DeleteUser(ID int) { delete(s.users, ID) } // Rozhrani predepisujici metody serveru type Server interface { Serve(port uint) } // Implementace HTTPServeru type ServerImpl struct { storage UserStorage } // Inicialiace serveru, predani kontextu func NewServer(storage UserStorage) Server { return ServerImpl{ storage: storage, } } func (s ServerImpl) Serve(port uint) { log.Printf("Starting server on port 8080") // REST API endpoints http.HandleFunc("/users", s.returnListOfUsers) http.HandleFunc("/user", s.returnOneUser) http.HandleFunc("/delete-user", s.deleteOneUser) // start the server http.ListenAndServe(fmt.Sprintf(":%d", port), nil) } // REST API handlery func (s ServerImpl) returnListOfUsers(writer http.ResponseWriter, r *http.Request) { users := s.storage.ReadListOfUsers() writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(users) } func (s ServerImpl) returnOneUser(writer http.ResponseWriter, r *http.Request) { params := r.URL.Query() IDs, found := params["ID"] if !found { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } ID, err := strconv.Atoi(IDs[0]) if err != nil { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } user, found := s.storage.ReadUser(ID) if !found { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusNotFound) return } writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(user) } func (s ServerImpl) deleteOneUser(writer http.ResponseWriter, r *http.Request) { params := r.URL.Query() IDs, found := params["ID"] if !found { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } ID, err := strconv.Atoi(IDs[0]) if err != nil { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } s.storage.DeleteUser(ID) writer.Header().Set("Content-Type", "application/json") status := struct { Status string ID int }{"deleted", ID} json.NewEncoder(writer).Encode(status) } func main() { storage := NewMemoryStorage() server := NewServer(storage) server.Serve(8080) }
12. Otestování chování upravené varianty webové služby
Po spuštění webové služby si otestujeme chování všech tří koncových bodů, které jsme implementovali:
$ curl localhost:8080/users | jq . [ { "Name": "Linus", "Surname": "Torvalds" }, { "Name": "Rob", "Surname": "Pike" }, { "Name": "Ken", "Surname": "Iverson" } ] $ curl localhost:8080/user?ID=1 | jq . { "Name": "Linus", "Surname": "Torvalds" } $ curl localhost:8080/delete-user?ID=1 {"Status":"deleted","ID":1}
Otestovat si můžeme i chování při chybných vstupech:
$ curl -v localhost:8080/delete-user * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET /delete-user HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.0.1 > Accept: */* > < HTTP/1.1 400 Bad Request < Content-Type: text/plain < Date: Wed, 30 Oct 2024 10:13:17 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
Navíc můžeme použít i HTTP metody, které nejsou v daném kontextu očekávány (což je implementační nedostatek):
$ curl -X DELETE localhost:8080/users [{"Name":"Rob","Surname":"Pike"},{"Name":"Ken","Surname":"Iverson"}]
13. Kontrola, zda je při volání endpointu použita očekávaná metoda
Abychom zabránili tomu, že bude nějaký endpoint volán nekorektní HTTP metodou, můžeme i ve starších verzích ekosystému jazyka Go explicitně zkontrolovat, jaká metoda byla použita. K tomu slouží atribut request.Method, který přímo obsahuje konstantu http.MethodGet, http.MethodPut atd. Kontrolu je tedy možné provést například následujícím způsobem:
func (s ServerImpl) returnListOfUsers(writer http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } ... ... ... }
Popř. test, zda je pro smazání uživatele skutečně použita HTTP metoda DELETE:
func (s ServerImpl) deleteOneUser(writer http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } ... ... ... }
Tyto kontroly sice nejsou ideální, ale v předchozích verzích Go nám nic jiného nezbývalo, pokud jsme nechtěli využít gorilla/mux a podobné knihovny třetích stran.
14. Úplný zdrojový kód třetí verze webové služby
Po výše zmíněných úpravách bude naše webová služba vypadat následovně:
package main import ( "encoding/json" "fmt" "log" "net/http" "strconv" ) // Datova struktura predstavujici uzivatele type User struct { Name string Surname string } // Rozhrani s predpisem metod pro manipulace s databazi uzivatelu type UserStorage interface { ReadListOfUsers() []User ReadUser(ID int) (User, bool) DeleteUser(ID int) } // Implementace databaze uzivatelu v operacni pameti type MemoryStorage struct { users map[int]User } // Inicializace databaze uzivatelu func NewMemoryStorage() MemoryStorage { users := make(map[int]User) users[1] = User{ Name: "Linus", Surname: "Torvalds", } users[2] = User{ Name: "Rob", Surname: "Pike", } users[3] = User{ Name: "Ken", Surname: "Iverson", } return MemoryStorage{ users: users, } } // Implementace vsech metod predepsanych rozhranim UserStorage func (s MemoryStorage) ReadListOfUsers() []User { users := make([]User, 0, len(s.users)) for _, user := range s.users { users = append(users, user) } return users } func (s MemoryStorage) ReadUser(ID int) (User, bool) { user, found := s.users[ID] return user, found } func (s MemoryStorage) DeleteUser(ID int) { delete(s.users, ID) } // Rozhrani predepisujici metody serveru type Server interface { Serve(port uint) } // Implementace HTTPServeru type ServerImpl struct { storage UserStorage } // Inicialiace serveru, predani kontextu func NewServer(storage UserStorage) Server { return ServerImpl{ storage: storage, } } func (s ServerImpl) Serve(port uint) { log.Printf("Starting server on port 8080") // REST API endpoints http.HandleFunc("/users", s.returnListOfUsers) http.HandleFunc("/user", s.returnOneUser) http.HandleFunc("/delete-user", s.deleteOneUser) // start the server http.ListenAndServe(fmt.Sprintf(":%d", port), nil) } // REST API handlery func (s ServerImpl) returnListOfUsers(writer http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } users := s.storage.ReadListOfUsers() writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(users) } func (s ServerImpl) returnOneUser(writer http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } params := r.URL.Query() IDs, found := params["ID"] if !found { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } ID, err := strconv.Atoi(IDs[0]) if err != nil { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } user, found := s.storage.ReadUser(ID) if !found { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusNotFound) return } writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(user) } func (s ServerImpl) deleteOneUser(writer http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } params := r.URL.Query() IDs, found := params["ID"] if !found { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } ID, err := strconv.Atoi(IDs[0]) if err != nil { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } s.storage.DeleteUser(ID) writer.Header().Set("Content-Type", "application/json") status := struct { Status string ID int }{"deleted", ID} json.NewEncoder(writer).Encode(status) } func main() { storage := NewMemoryStorage() server := NewServer(storage) server.Serve(8080) }
15. Otestování, jak webová služba kontroluje HTTP metody požadavků
Pokusme se nyní zavolat koncové body upravené webové služby tak, že vždy použijeme HTTP metody, které neodpovídají daným koncovým bodům:
$ curl -v -X DELETE localhost:8080/users * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > DELETE /users HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.0.1 > Accept: */* > < HTTP/1.1 400 Bad Request < Content-Type: text/plain < Date: Wed, 30 Oct 2024 12:49:30 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
$ curl -v -X GET localhost:8080/delete-user?ID=1 * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET /delete-user?ID=1 HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.0.1 > Accept: */* > < HTTP/1.1 400 Bad Request < Content-Type: text/plain < Date: Wed, 30 Oct 2024 12:50:02 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
$ curl -v -X POST localhost:8080/user?ID=1 * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > POST /user?ID=1 HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.0.1 > Accept: */* > < HTTP/1.1 400 Bad Request < Content-Type: text/plain < Date: Wed, 30 Oct 2024 12:50:24 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
Webová služba tedy pracuje – minimálně z tohoto pohledu – korektně.
16. Nedostatky webové služby a jejich náprava v Go 1.22
I přes nepatrná vylepšení má naše webová služba mnoho nedostatků. Zejména se používá špatné pojmenování koncových bodů a taktéž při „adresování“ zdrojů (což jsou v našem případě osoby) se spíše používá zápis zdroje přímo do URL, tedy například ve stylu /users/1, /users/42 atd. Všechny tyto nedostatky je možné relativně snadno opravit právě v Go verze 1.22 nebo pochopitelně v jakékoli novější verzi Go. Při registraci handlerů je totiž možné specifikovat používanou HTTP metodu a navíc může adresa obsahovat měnitelné části, které se při konkrétním volání nahradí například právě ID konkrétní osoby. Konkrétně to znamená, že registraci handlerů můžeme zjednodušit na pouhé:
// REST API endpoints http.HandleFunc("GET /users", s.returnListOfUsers) http.HandleFunc("GET /users/{id}", s.returnOneUser) http.HandleFunc("DELETE /users/{id}", s.deleteOneUser)
Pokud tedy webovou službu voláme a používáme zdánlivě stejný koncový bod /users, bude náš HTTP server nejdříve rozlišovat použitou metodu (tím především rozliší druhý a třetí handler). Taktéž se bude snažit zjistit, která adresa je nejvíce konkrétní, čímž odliší první handler od ostatních dvou. Výsledkem bude webová služba s korektně pojmenovanými koncovými body (i když je samozřejmě na delší diskusi, zda použít singulár či plurál; zde jsem zvolil druhou možnost).
To však ještě není vše, protože potřebujeme získat ID osoby, což bylo v předchozích dvou variantách relativně zdlouhavé. Nyní je situace mnohem lepší, protože můžeme zavolat metodu PathValue rozhraní http.Request. Tato metoda vrátí příslušnou proměnnou část adresy, a to formou řetězce (jeden řetězec, nikoli řez ani pole). Je tedy stále nutné provést konverzi řetězce na celé číslo, například tak, jak je to naznačeno v ukázkovém kódu:
func (s ServerImpl) returnOneUser(writer http.ResponseWriter, r *http.Request) { IDs := r.PathValue("id") ID, err := strconv.Atoi(IDs) if err != nil { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } ... ... ... }
http.HandleFunc("GET /users", s.returnListOfUsers) http.HandleFunc("GET /users/{id}", s.returnOneUser) http.HandleFunc("DELETE /users/{id}", s.deleteOneUser) http.HandleFunc("GET /users/{id}/name/{name}/surname/{surname}", s.returnOneUserByNameSurname) http.HandleFunc("GET /users/{id}/name/{id}", s.returnOneUserByID)
Takto upravená služba sice bude přeložitelná, ale v čase běhu (runtime) zhavaruje kvůli poslednímu registrovanému handleru:
panic: parsing "GET /users/{id}/name/{id}": at offset 21: duplicate wildcard name "id" goroutine 1 [running]: net/http.(*ServeMux).register(...) /usr/local/go/src/net/http/server.go:2733 net/http.HandleFunc({0x6af8a5?, 0x0?}, 0x2?) /usr/local/go/src/net/http/server.go:2727 +0x86 main.ServerImpl.Serve({{0x722ef8?, 0xc00011c960?}}, 0x1f90) /home/ptisnovs/xy/api_service_5.go:94 +0x259 main.main() /home/ptisnovs/xy/api_service_5.go:151 +0x151 exit status 2
17. Úplný zdrojový kód čtvrté verze webové služby
Čtvrtá a současně i poslední varianta naší webové služby je již mnohem čitelnější a kratší, než varianty předchozí. Navíc mají koncové body korektní sémantiku:
package main import ( "encoding/json" "fmt" "log" "net/http" "strconv" ) // Datova struktura predstavujici uzivatele type User struct { Name string Surname string } // Rozhrani s predpisem metod pro manipulace s databazi uzivatelu type UserStorage interface { ReadListOfUsers() []User ReadUser(ID int) (User, bool) DeleteUser(ID int) } // Implementace databaze uzivatelu v operacni pameti type MemoryStorage struct { users map[int]User } // Inicializace databaze uzivatelu func NewMemoryStorage() MemoryStorage { users := make(map[int]User) users[1] = User{ Name: "Linus", Surname: "Torvalds", } users[2] = User{ Name: "Rob", Surname: "Pike", } users[3] = User{ Name: "Ken", Surname: "Iverson", } return MemoryStorage{ users: users, } } // Implementace vsech metod predepsanych rozhranim UserStorage func (s MemoryStorage) ReadListOfUsers() []User { users := make([]User, 0, len(s.users)) for _, user := range s.users { users = append(users, user) } return users } func (s MemoryStorage) ReadUser(ID int) (User, bool) { user, found := s.users[ID] return user, found } func (s MemoryStorage) DeleteUser(ID int) { delete(s.users, ID) } // Rozhrani predepisujici metody serveru type Server interface { Serve(port uint) } // Implementace HTTPServeru type ServerImpl struct { storage UserStorage } // Inicialiace serveru, predani kontextu func NewServer(storage UserStorage) Server { return ServerImpl{ storage: storage, } } func (s ServerImpl) Serve(port uint) { log.Printf("Starting server on port 8080") // REST API endpoints http.HandleFunc("GET /users", s.returnListOfUsers) http.HandleFunc("GET /users/{id}", s.returnOneUser) http.HandleFunc("DELETE /users/{id}", s.deleteOneUser) // start the server http.ListenAndServe(fmt.Sprintf(":%d", port), nil) } // REST API handlery func (s ServerImpl) returnListOfUsers(writer http.ResponseWriter, r *http.Request) { users := s.storage.ReadListOfUsers() writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(users) } func (s ServerImpl) returnOneUser(writer http.ResponseWriter, r *http.Request) { IDs := r.PathValue("id") ID, err := strconv.Atoi(IDs) if err != nil { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } user, found := s.storage.ReadUser(ID) if !found { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusNotFound) return } writer.Header().Set("Content-Type", "application/json") json.NewEncoder(writer).Encode(user) } func (s ServerImpl) deleteOneUser(writer http.ResponseWriter, r *http.Request) { IDs := r.PathValue("id") ID, err := strconv.Atoi(IDs) if err != nil { writer.Header().Set("Content-Type", "text/plain") writer.WriteHeader(http.StatusBadRequest) return } s.storage.DeleteUser(ID) writer.Header().Set("Content-Type", "application/json") status := struct { Status string ID int }{"deleted", ID} json.NewEncoder(writer).Encode(status) } func main() { storage := NewMemoryStorage() server := NewServer(storage) server.Serve(8080) }
18. Otestování základních funkcí poslední varianty webové služby
Nyní si již můžeme ověřit, jak pracuje (či naopak nepracuje) naše poslední varianta webové služby. Získání seznamu všech osob:
$ curl localhost:8080/users | jq . [ { "Name": "Linus", "Surname": "Torvalds" }, { "Name": "Rob", "Surname": "Pike" }, { "Name": "Ken", "Surname": "Iverson" } ]
Výběr (selekce) osoby na základě jejího ID a použití HTTP metody GET:
$ curl localhost:8080/users/1 | jq . { "Name": "Linus", "Surname": "Torvalds" }
Vymazání osoby na základě jejího ID a použití HTTP metody DELETE:
$ curl -X DELETE localhost:8080/users/1 {"Status":"deleted","ID":1}
Seznam osob, nyní již obsahující pouze dva záznamy:
$ curl localhost:8080/users | jq . [ { "Name": "Rob", "Surname": "Pike" }, { "Name": "Ken", "Surname": "Iverson" } ]
Pokusy o použití nekorektní HTTP metody:
$ curl -X PUT localhost:8080/users Method Not Allowed $ curl -X POST localhost:8080/users Method Not Allowed $ curl -X DELETE localhost:8080/users Method Not Allowed
19. Repositář s demonstračními příklady
Zdrojové kódy všech dnes popsaných demonstračních příkladů byly uloženy do Git repositáře, který je dostupný na adrese https://github.com/tisnik/go-root (stále na GitHubu :-). V případě, že nebudete chtít klonovat celý repositář, můžete namísto toho použít odkazy na jednotlivé příklady, které naleznete v následujících tabulkách.
Klasická implementace HTTP serverů založených pouze na základních knihovnách jazyka Go
# | Soubor | Popis | Cesta |
---|---|---|---|
1 | 01_server.go | jednoduchý HTTP server posílající dynamicky generovaný obsah | https://github.com/tisnik/go-root/blob/master/article24/01_server.go |
2 | 02_slow_server.go | zpomalení generování jednotlivých bloků generovaného obsahu | https://github.com/tisnik/go-root/blob/master/article24/02_slow_server.go |
3 | 03_flushing_server.go | využití metody Flush z rozhraní Flusher | https://github.com/tisnik/go-root/blob/master/article24/03_flushing_server.go |
4 | 04_close_detector.go | test, zda klient neukončil spojení | https://github.com/tisnik/go-root/blob/master/article24/04_close_detector.go |
HTTP servery založené na knihovně Gorilla mux
HTTP servery založené na základních knihovnách jazyka Go verze 1.22 a vyšších
# | Soubor | Popis | Cesta |
---|---|---|---|
1 | api_service1.go | základní struktura webové služby, zde jen s jediným koncovým bodem | https://github.com/tisnik/go-root/blob/master/article_AE/api_service1.go |
2 | api_service2.go | přidání handlerů pro další koncové body (ovšem bez korektní sémantiky) | https://github.com/tisnik/go-root/blob/master/article_AE/api_service2.go |
3 | api_service3.go | test, zda je použita korektní HTTP metoda | https://github.com/tisnik/go-root/blob/master/article_AE/api_service3.go |
4 | api_service4.go | korektní sémantika koncových bodů i jejich handlerů | https://github.com/tisnik/go-root/blob/master/article_AE/api_service4.go |
5 | api_service5.go | duplikátní proměnné části při registraci koncových bodů | https://github.com/tisnik/go-root/blob/master/article_AE/api_service5.go |
20. Odkazy na Internetu
- Go 1.22 Release Notes
https://go.dev/doc/go1.22#enhanced_routing_patterns - Dokumentace k balíčku gorilla/mux
https://godoc.org/github.com/gorilla/mux - Gorilla web toolkitk
http://www.gorillatoolkit.org/ - Metric types
https://prometheus.io/docs/concepts/metric_types/ - Histograms with Prometheus: A Tale of Woe
http://linuxczar.net/blog/2017/06/15/prometheus-histogram-2/ - Instrumenting Golang server in 5 min
https://medium.com/@gsisimogang/instrumenting-golang-server-in-5-min-c1c32489add3 - Routing Enhancements for Go 1.22
https://go.dev/blog/routing-enhancements - net/http: enhanced ServeMux routing #61410
https://github.com/golang/go/issues/61410 - net/http: add methods and path variables to ServeMux patterns #60227
https://github.com/golang/go/discussions/60227 - curl man page
https://curl.se/docs/manpage.html