Все примеры кода можно найти здесь.
В этой статье мы попытаемся понять две важнейшие концепции Go — mux
и Handler
. Но прежде чем мы начнем писать какой-либо код, давайте сначала разберемся, что нам нужно для запуска простого веб-сервиса.
- Прежде всего, нам нужен сам сервер, который будет работать на некотором порту, прослушивать запросы и предоставлять ответы на них.
- Следующее, что нам нужно, — это маршрутизатор. Этот элемент отвечает за маршрутизацию запросов к соответствующим обработчикам. В мире Go servemux фактически является аналогом маршрутизатора.
- Последнее, что нам нужно, — это обработчик. Он отвечает за обработку запроса, выполнение бизнес-логики вашего приложения и предоставление ответа на него.
Реализация интерфейса обработчика
Давайте начнем наше путешествие с чистого кода Go, без библиотек и фреймворков. Чтобы запустить сервер, нам нужно реализовать интерфейс Handler:
type Handler interface{
ServeHTTP(ResponseWriter, *Request)
}
Для этого нам нужно создать пустую структуру и предоставить для нее метод:
package main
import (
"fmt"
"net/http"
)
type handler struct{}
func (t *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ping %sn", r.URL.Query().Get("name"))
}
func main() {
h := &handler{} //1
http.Handle("/", h) //3
http.ListenAndServe(":8000", nil) //4
}
- Мы создаем пустую структуру, которая реализует интерфейс
http.Handler
. -
http.Handle
зарегистрирует нашhandler
для заданного шаблона, в нашем случае это «/». Почему шаблон, а не URI? Потому что под капотом, когда ваш сервер работает и получает любой запрос — он найдет ближайший паттерн к пути запроса и отправит запрос соответствующему обработчику. Это означает, что если вы попытаетесь вызвать ‘http://localhost:8000/some/other/path?value=foo’, он все равно будет отправлен на наш зарегистрированный обработчик, даже если он зарегистрирован по шаблону «/». - В последней строке с помощью
http.ListenAndServe
мы запускаем сервер на порту 8000. Не забывайте о втором аргументе, который пока является нулевым, но мы рассмотрим его подробнее через несколько мгновений.
Давайте проверим, как это работает, используя curl:
❯ curl "localhost:8000?name=foo"
ping foo
Использование функции-обработчика
Следующий пример очень похож на предыдущий, но он немного короче и проще в разработке. Нам не нужно реализовывать какой-либо интерфейс, достаточно создать функцию с сигнатурой, похожей на Handler.ServeHTTP
.
package main
import (
"fmt"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ping %sn", r.URL.Query().Get("name"))
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8000", nil)
}
Обратите внимание, что под капотом это просто синтаксический сахар для того, чтобы избежать создания экземпляров Handler на каждом шаблоне. HandleFunc
— это адаптер, который преобразует его в struct с методом serveHTTP
. Поэтому вместо этого
type root struct{}
func (t *root) ServeHTTP(w http.ResponseWriter, r *http.Request) {...}
type home struct{}
func (t *home) ServeHTTP(w http.ResponseWriter, r *http.Request) {...}
type login struct{}
func (t *login) ServeHTTP(w http.ResponseWriter, r *http.Request) {...}
//...
http.Handle("/", root)
http.Handle("/home", home)
http.Handle("/login", login)
мы можем просто использовать этот подход:
func root(w http.ResponseWriter, r *http.Request) {...}
func home(w http.ResponseWriter, r *http.Request) {...}
func login(w http.ResponseWriter, r *http.Request) {...}
...
http.HandleFunc("/", root)
http.HandleFunc("/home", home)
http.HandleFunc("/login", login)
Создание собственного ServeMux
Помните, мы передали nil
в http.ListenAndServe
? Так вот, под капотом пакет http будет использовать ServeMux по умолчанию и привязывать к нему обработчики с помощью http.Handle
и http.HandleFunc
. В продакшене использование дефолтного serveMux не является хорошим шаблоном, потому что это глобальная переменная, поэтому любой пакет может получить к ней доступ и зарегистрировать новый маршрутизатор или что-то еще хуже. Поэтому давайте создадим свой собственный serveMux. Для этого воспользуемся функцией http.NewServeMux
. Она возвращает экземпляр ServeMux, который также имеет методы Handle
и HandleFunc
.
mux := http.NewServeMux()
mux.HandleFunc("/", handlerFunc)
http.ListenAndServe(":8000", mux)
Интересно то, что наш mux
тоже является обработчиком. Сигнатура http.ListenAndServe
ждет обработчик в качестве второго аргумента и после получения запроса наш HTTP сервер вызовет serveHTTP
метод нашего mux, который в свою очередь вызовет serveHTTP
метод зарегистрированных обработчиков.
Таким образом, не обязательно предоставлять mux из http.NewServeMux()
. Чтобы понять это, давайте создадим собственный экземпляр маршрутизатора.
package custom_router
import (
"fmt"
"net/http"
)
type router struct{}
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/foo":
fmt.Fprint(w, "here is /foo")
case "/bar":
fmt.Fprint(w, "here is /bar")
case "/baz":
fmt.Fprint(w, "here is /baz")
default:
http.Error(w, "404 Not Found", 404)
}
}
func main() {
var r router
http.ListenAndServe(":8000", &r)
}
И проверим это:
❯ curl "localhost:8000/foo"
here is /foo
Имейте в виду, что servemux, предоставляемый Go, будет обрабатывать каждый запрос в отдельной goroutine. Вы можете попробовать реализовать собственный маршрутизатор, действующий как Go mux, используя goroutines для каждого запроса.
Создание собственного сервера
Что произойдет, если клиент обратится к нашей конечной точке, которой нужно получить информацию из БД, но БД долго не отвечает? Будет ли клиент ждать ответа? Если да, то, вероятно, это не очень удобный API. Поэтому, чтобы избежать такой ситуации и предоставить ответ после некоторого времени ожидания, мы можем инстанцировать http.Server
, который имеет функцию ListenAndServe
. На производстве нам часто приходится настраивать наш сервер, например, предоставлять нестандартный логгер или устанавливать таймауты.
srv := &http.Server{
Addr:":8000",
Handler: mux,
// ReadTimeout is the maximum duration for reading the entire
// request, including the body.
ReadTimeout: 5 * time.Second,
// WriteTimeout is the maximum duration before timing out
// writes of the response.
WriteTimeout: 2 * time.Second,
}
srv.ListenAndServe()
Поведение таймаутов может показаться неочевидным. Что произойдет, когда тайм-аут будет превышен? Будет ли запрос принудительно завершен или на него будет дан ответ? Давайте создадим обработчик, который будет спать 2 секунды, и посмотрим, как будет вести себя наш сервер при WriteTimeout в 1 секунду.
func handler(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
fmt.Fprintf(w, "ping %sn", r.URL.Query().Get("name"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
srv := &http.Server{
Addr: ":8000",
Handler: mux,
WriteTimeout: 1 * time.Second,
}
srv.ListenAndServe()
}
Теперь вызовем curl
с помощью утилиты time
, чтобы измерить, сколько времени займет наш запрос.
❯ time curl "http://localhost:8000?name=foo"
curl: (52) Empty reply from server
curl "http://localhost:8000?name=foo" 0.00s user 0.01s system 0% cpu 2.022 total
Мы не видим ответа от сервера, а запрос занял 2 секунды вместо 1. Это потому, что таймауты — это просто механизм, который ограничивает определенные действия после превышения таймаута. В нашем случае запись чего-либо в ответ была ограничена по истечении 1 секунды. В конце я привел 2 ссылки на замечательные статьи.
И все же у нас остался открытый вопрос: как заставить наш обработчик завершить работу после некоторого периода времени?
Для этого мы можем просто использовать метод http
TimeoutHandler
:
func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler
Давайте перепишем наш пример с обработчиком таймаута. Не забудьте увеличить time.sleep и timeout на +1 секунду каждый, иначе ответа все равно не будет:
func handler(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
fmt.Fprintf(w, "ping %sn", r.URL.Query().Get("name"))
}
func main() {
mux := http.NewServeMux()
mux.Handle("/", http.TimeoutHandler(http.HandlerFunc(handler), time.Second * 1, "Timeout"))
srv := &http.Server{
Addr: ":8000",
Handler: mux,
WriteTimeout: 2 * time.Second,
}
srv.ListenAndServe()
}
Теперь наш curl работает именно так, как мы ожидали:
❯ time curl "http://localhost:8000?name=foo"
Timeoutcurl "http://localhost:8000?name=foo" 0.01s user 0.01s system 1% cpu 1.022 total
RESTful маршрутизация
Servemux, предоставляемый Go, не имеет удобного способа поддержки HTTP методов. Конечно, мы всегда можем добиться того же результата, используя *http.Request
, который содержит всю необходимую информацию об ответе, включая метод HTTP:
package main
import"net/http"
func createUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", 405)
}
w.Write([]byte("New user has been created"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/users", createUser)
http.ListenAndServe(":3000", mux)
}
Пользовательский RESTful-маршрутизатор
Теперь давайте попробуем сделать что-то более интересное. Нам нужно, чтобы наш маршрутизатор мог регистрировать обработчики с помощью метода handleFunc
, который будет принимать 3 параметра:
method string,
pattern string,
f func(w http.ResponseWriter, req *http.Request)
Для этого нам нужно написать небольшую кучку кода 🙂
Давайте начнем с типов. Сначала нам нужен сам маршрутизатор. Он должен иметь карту, которая будет хранить зарегистрированный шаблон URL (например, /users
) и всю информацию (или правила), которую мы хотим применить к нему:
type urlPattern string
type router struct {
routes map[urlPattern]routeRules
}
func New() *router {
return &router{routes: make(map[urlPattern]routeRules)}
}
Далее давайте определим, что такое routeRules. В случае REST мы хотим хранить зарегистрированные HTTP-методы и связанные с ними обработчики:
type httpMethod string
type routeRules struct {
methods map[httpMethod]http.Handler
}
Теперь мы хотим, чтобы у нашего маршрутизатора был метод HandleFunc
:
/*
method - string, e.g. POST, GET, PUT
pattern - URL path for which we want to register a handler
f - handler
*/
func (r *router) HandleFunc(method httpMethod, pattern urlPattern, f func(w http.ResponseWriter, req *http.Request)) {
rules, exists := r.routes[pattern]
if !exists {
rules = routeRules{methods: make(map[httpMethod]http.Handler)}
r.routes[pattern] = rules
}
rules.methods[method] = http.HandlerFunc(f)
}
Последнее, что нам нужно, это чтобы наш маршрутизатор реализовывал интерфейс Handler
. Поэтому нам нужно реализовать метод ServeHTTP(w http.ResponseWriter, req *http.Request)
:
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// first we will try to find a registered URL pattern
foundPattern, exists := r.routes[urlPattern(req.URL.Path)]
if !exists {
http.NotFound(w, req)
return
}
// next we will try to check if such HTTP method was registered
handler, exists := foundPattern.methods[httpMethod(req.Method)]
if !exists {
notAllowed(w, req, foundPattern)
return
}
// finally we will call registered handler
handler.ServeHTTP(w, req)
}
// small helper method
func notAllowed(w http.ResponseWriter, req *http.Request, r routeRules) {
methods := make([]string, 1)
for k := range r.methods {
methods = append(methods, string(k))
}
w.Header().Set("Allow", strings.Join(methods, " "))
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
Вот и все. Теперь давайте зарегистрируем простой обработчик:
func handler(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("hello"))
}
func main() {
r := New()
r.HandleFunc(http.MethodGet, "/test", handler)
http.ListenAndServe(":8000", r)
}
И проверим, как он работает:
❯ curl -X GET -i "http://localhost:8000/test"
HTTP/1.1 200 OK
Date: Wed, 13 Jul 2022 14:24:43 GMT
Content-Length: 5
Content-Type: text/plain; charset=utf-8
hello
❯ curl -X POST -i "http://localhost:8000/test"
HTTP/1.1 405 Method Not Allowed
Allow: GET
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Wed, 13 Jul 2022 14:24:14 GMT
Content-Length: 19
Method Not Allowed
Потрясающе. Мы только что написали маршрутизатор, который может легко зарегистрировать обработчик для определенного метода HTTP. Полный код выглядит следующим образом:
package main
import (
"net/http"
"strings"
)
type httpMethod string
type urlPattern string
type routeRules struct {
methods map[httpMethod]http.Handler
}
type router struct {
routes map[urlPattern]routeRules
}
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
foundRoute, exists := r.routes[urlPattern(req.URL.Path)]
if !exists {
http.NotFound(w, req)
return
}
handler, exists := foundRoute.methods[httpMethod(req.Method)]
if !exists {
notAllowed(w, req, foundRoute)
return
}
handler.ServeHTTP(w, req)
}
func (r *router) HandleFunc(method httpMethod, pattern urlPattern, f func(w http.ResponseWriter, req *http.Request)) {
rules, exists := r.routes[pattern]
if !exists {
rules = routeRules{methods: make(map[httpMethod]http.Handler)}
r.routes[pattern] = rules
}
rules.methods[method] = http.HandlerFunc(f)
}
func notAllowed(w http.ResponseWriter, req *http.Request, r routeRules) {
methods := make([]string, 1)
for k := range r.methods {
methods = append(methods, string(k))
}
w.Header().Set("Allow", strings.Join(methods, " "))
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
func New() *router {
return &router{routes: make(map[urlPattern]routeRules)}
}
func handler(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("hello"))
}
func main() {
r := New()
r.HandleFunc(http.MethodGet, "/test", handler)
http.ListenAndServe(":8000", r)
}
Почему бы не использовать это в prod? Ну, потому что есть несколько библиотек, которые предлагают вам такие возможности и многое другое! Ограничения по заголовку хоста, обработка путей с параметрами пути, параметры запроса, сопоставление шаблонов (мы реализуем только точное равенство) и многое другое.
Gorilla mux
Одна из самых популярных библиотек для этого — Gorilla/mux.
❯ go get "github.com/gorilla/mux"
Вот простой пример обработчика GET.
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World!")
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/test", handler).Methods("GET")
http.ListenAndServe(":8000", r)
}
Давайте проверим эту конечную точку и посмотрим результат:
❯ curl -X GET "http://localhost:8000/test"
Hello World!
А если мы попытаемся отправить запрос методом POST, то получим 405:
❯ curl -X POST -i "http://localhost:8000/test"
HTTP/1.1 405 Method Not Allowed
Date: Wed, 13 Jul 2022 12:54:22 GMT
Content-Length: 0
Gorilla mux:
https://github.com/gorilla/mux
Статьи с таймаутом:
https://ieftimov.com/posts/make-resilient-golang-net-http-servers-using-timeouts-deadlines-context-cancellation/
https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/