В начале февраля 2024 года вышел Go 1.22. Вот, что нового и интересного принёс новый релиз: сделали более безопасное поведение переменных в циклах, добавили функции-итераторы в качестве rangefunc-эксперимента и улучшили шаблоны роутинга. В этой статье я сфокусируюсь на последнем, самом долгожданном, для многих, обновлении — шаблонах http-роутинга.
Роутинг в Go — общая проблема, для решения которой уже построили кучу фреймворков, в этом GitHub-репозитории собраны лучшие. Google сама признаётся, что они вдохновлялись сторонними решениями и лучшее добавили в net/http.
С приходом Go 1.22 всё необходимое для роутинга из коробки умеет делать http.ServeMux: он различает HTTP-методы, хосты и домены, а также может шаблонизировать пути через плейсхолдеры.
Давайте поднимем сервер на localhost и поэксперементируем с тем, как ведёт себя ServeMux с разными шаблонами. Представим, что у нас есть некоторый сервер блога, у которого есть ручки posts, /posts/{id} и /posts/latest для того, чтобы дёргать посты. Напишем простенький обработчик и настроим сервер на 7777 порт.
package main
import (
"fmt"
"net/http"
)
func h(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s: Вы вызвали %s методом %s\n", name, r.URL.String(), r.Method)
}
}
func main() {
m := http.NewServeMux()
m.Handle("GET /posts/latest", h("latest"))
m.Handle("GET /posts/{id}", h("id"))
m.Handle("GET /posts", h("posts"))
http.ListenAndServe(":7777", m)
}
Теперь будем немного менять муксер и курлом дёргать разные пути.
Как было раньше до 1.22. Для пути /posts используется один и тот же обработчик — вне зависимости от метода. Метод определяется уже внутри обработчика, это не очень удобно.
m.Handle("/posts", h("posts-no-method"))
1) Вызовем методом GET: curl localhost:7777/posts
Вывод: posts-no-method: Вы вызвали /posts методом GET
2) Вызовем методом POST: curl -X POST localhost:7777/posts
Вывод: posts-no-method: Вы вызвали /posts методом POST
Причём Go сам никак не валидирует указанный метод. Можно вызвать тот же путь с методом AVITO, например, — и всё отработает без ошибок.
3) Вызовем методом AVITO: curl -X AVITO localhost:7777/posts
Вывод: posts-no-method: Вы вызвали /posts методом AVITO
Как теперь в 1.22. Если явно указать метод в шаблоне, то нужный обработчик вызывается только для запроса с этим методом. Обратите внимание: при указании метода GET зарегистрируется обработчик и для GET, и для HEAD.
При этом у шаблонов с методом приоритет выше, чем у шаблонов без него.
m.Handle("GET /posts", h("posts-with-method"))
1) Вызовем методом GET: curl localhost:7777/posts
Вывод: posts-with-method: Вы вызвали /posts методом GET
2) Вызовем методом POST: curl -X POST localhost:7777/posts
Вывод: Method Not Allowed (со статусом 405)
Как было раньше до 1.22. Вне зависимости от хоста для одного пути вызывается один и тот же обработчик. Вернёмся к прежнему шаблону:
m.Handle("/posts", h("posts-no-host"))
1) Вызовем с хостом localhost: curl localhost:7777/posts
Вывод: posts-no-host: Вы вызвали posts методом GET
2) Вызовем с хостом 127.0.0.1:7777: curl 127.0.0.1:7777/posts
Вывод: posts-no-host: Вы вызвали posts методом GET— аналогичное поведение.
Как теперь в 1.22. Можно назначить разные обработчики на один и тот же путь в зависимости от того, какой хост используется при вызове.
m.Handle("localhost/posts", h("posts-with-localhost"))
m.Handle("127.0.0.1/posts", h("posts-with-127-0-0-1"))
Обратите внимание, между хостом у путём не должно быть пробела.
1) Вызовем с хостом localhost: curl localhost:7777/posts
Вывод: posts-with-localhost: Вы вызвали /posts методом GET
2) Вызовем с хостом 127.0.0.1: curl 127.0.0.1:7777/posts
Вывод: posts-with-127-0-0-1: Вы вызвали /posts методом GET
Как было раньше до 1.22. Если требуется обработать пути вида /posts/{id}, то используют слеш на конце пути. Например, при указании шаблона "/posts/" все пути, начинающиеся на /posts/ обрабатываются с помощью одного обработчика. Из-за этого в обработчике приходится отдельно вытаскивать id поста.
m.Handle("/posts/", h("posts-with-slash"))
1) Вызовем /posts/1: curl localhost:7777/posts/1
Вывод: posts-with-slash: Вы вызвали /posts/1 методом GET
2) Вызовем /posts/latest: curl localhost:7777/posts/latest
Вывод: posts-with-slash: Вы вызвали /posts/latest методом GET
/posts/1 и /posts/latest, скорее всего, должны отдавать разный контент, но оба пути используют один обработчик posts-with-slash.
Как теперь в 1.22. Можно использовать плейсхолдеры в шаблоне запроса для более точного роутинга. Например, /posts/{id} будет соответствовать всем URL, которые начинаются на /posts/ и содержат два сегмента.
m.Handle("GET /posts/{id}", h("id"))
m.Handle("GET /posts/latest", ("latest"))
1) Вызовем /posts/1: curl localhost:7777/posts/1
Вывод: id: Вы вызвали /posts/1 методом GET
2) Вызовем /posts/latest: curl localhost:7777/posts/latest
Вывод: latest: Вы вызвали /posts/latest методом GET
А id поста можно легко вытащить таким образом:
idString := req.PathValue("id")
Плейсхолдер может соответствовать целому сегменту, как {id} в примере выше, или, если он заканчивается на ..., — всем оставшимся сегментам пути, как в шаблоне /files/{pathname...}.
Для обозначения конца пути можно использовать специальный знак {$}. Например, /posts/{$} будет соответствовать только /posts/, но не /posts или /posts/123/.
В Go допустим конфликт шаблонов. Например, шаблоны /posts/{id} и /posts/latest перекрывают друг друга. При вызове /posts/latest непонятно, какой обработчик нужно использовать. Давайте разберёмся, какие шаблоны имеют наивысший приоритет.
В версиях до 1.22 выбирается более длинный шаблон — независимо от их порядка. Например, Go предпочтёт /posts/latest, а не /posts/.
Теперь в 1.22 при конфликтах выбирается наиболее конкретный шаблон. Например, Go выберет /posts/latest вместо /posts/{id}. А вместо /users/{u}/posts/{id} выберет /users/{u}/posts/latest.
Для методов — аналогично. Например, GET /posts/{id} имеет приоритет над /posts/{id}, потому что первый соответствует только запросам GET и HEAD, а второй — запросам с любым методом, то есть такой шаблон менее конкретный.
Для хостов — по-другому. Для них пришлось сделать исключение, чтобы сохранить совместимость. Если два шаблона конфликтуют, но у одного явно указан хост, а у другого — нет, то выбирается шаблон с хостом.
Если два шаблона конфликтуют, но среди них нельзя выделить наиболее конкретный, вызовется паника. Например, /posts/latest подходит под шаблоны /posts/{id} и /{resource}/latest. В каком бы порядке вы ни зарегистрировали эти шаблоны, при регистрации в обработчике /posts/latest произойдёт паника. То есть до запуска сервера с таким роутингом дело не дойдёт.
Изменения в роутинге ломают обратную совместимость. Например, предыдущие версии Go принимали шаблоны с фигурными скобками и трактовали их буквально, а в версии 1.22 используются фигурные скобки для подстановочных знаков.
Старое поведение можно вернуть, задав GODEBUG-переменную окружения: GODEBUG=httpmuxgo121=1.
Кроме того, проверьте, какая версия Go установлена у вас в go.mod. Если там версия ниже 1.22, то весь роутинг будет работать в режиме совместимости, и все нововведения будут отключены. Поднять версию в go.mod можно просто отредактировав его руками, или командой go mod edit -go=1.22.2 (укажите вашу версию Go).
Роутинг в Go — общая проблема, для решения которой уже построили кучу фреймворков, в этом GitHub-репозитории собраны лучшие. Google сама признаётся, что они вдохновлялись сторонними решениями и лучшее добавили в net/http.
С приходом Go 1.22 всё необходимое для роутинга из коробки умеет делать http.ServeMux: он различает HTTP-методы, хосты и домены, а также может шаблонизировать пути через плейсхолдеры.
Разберём роутинг на примере блога
Давайте поднимем сервер на localhost и поэксперементируем с тем, как ведёт себя ServeMux с разными шаблонами. Представим, что у нас есть некоторый сервер блога, у которого есть ручки posts, /posts/{id} и /posts/latest для того, чтобы дёргать посты. Напишем простенький обработчик и настроим сервер на 7777 порт.
package main
import (
"fmt"
"net/http"
)
func h(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s: Вы вызвали %s методом %s\n", name, r.URL.String(), r.Method)
}
}
func main() {
m := http.NewServeMux()
m.Handle("GET /posts/latest", h("latest"))
m.Handle("GET /posts/{id}", h("id"))
m.Handle("GET /posts", h("posts"))
http.ListenAndServe(":7777", m)
}
Теперь будем немного менять муксер и курлом дёргать разные пути.
HTTP-методы в шаблонах
Как было раньше до 1.22. Для пути /posts используется один и тот же обработчик — вне зависимости от метода. Метод определяется уже внутри обработчика, это не очень удобно.
m.Handle("/posts", h("posts-no-method"))
1) Вызовем методом GET: curl localhost:7777/posts
Вывод: posts-no-method: Вы вызвали /posts методом GET
2) Вызовем методом POST: curl -X POST localhost:7777/posts
Вывод: posts-no-method: Вы вызвали /posts методом POST
Причём Go сам никак не валидирует указанный метод. Можно вызвать тот же путь с методом AVITO, например, — и всё отработает без ошибок.
3) Вызовем методом AVITO: curl -X AVITO localhost:7777/posts
Вывод: posts-no-method: Вы вызвали /posts методом AVITO
Как теперь в 1.22. Если явно указать метод в шаблоне, то нужный обработчик вызывается только для запроса с этим методом. Обратите внимание: при указании метода GET зарегистрируется обработчик и для GET, и для HEAD.
При этом у шаблонов с методом приоритет выше, чем у шаблонов без него.
m.Handle("GET /posts", h("posts-with-method"))
1) Вызовем методом GET: curl localhost:7777/posts
Вывод: posts-with-method: Вы вызвали /posts методом GET
2) Вызовем методом POST: curl -X POST localhost:7777/posts
Вывод: Method Not Allowed (со статусом 405)
Хосты в шаблонах
Как было раньше до 1.22. Вне зависимости от хоста для одного пути вызывается один и тот же обработчик. Вернёмся к прежнему шаблону:
m.Handle("/posts", h("posts-no-host"))
1) Вызовем с хостом localhost: curl localhost:7777/posts
Вывод: posts-no-host: Вы вызвали posts методом GET
2) Вызовем с хостом 127.0.0.1:7777: curl 127.0.0.1:7777/posts
Вывод: posts-no-host: Вы вызвали posts методом GET— аналогичное поведение.
Как теперь в 1.22. Можно назначить разные обработчики на один и тот же путь в зависимости от того, какой хост используется при вызове.
m.Handle("localhost/posts", h("posts-with-localhost"))
m.Handle("127.0.0.1/posts", h("posts-with-127-0-0-1"))
Обратите внимание, между хостом у путём не должно быть пробела.
1) Вызовем с хостом localhost: curl localhost:7777/posts
Вывод: posts-with-localhost: Вы вызвали /posts методом GET
2) Вызовем с хостом 127.0.0.1: curl 127.0.0.1:7777/posts
Вывод: posts-with-127-0-0-1: Вы вызвали /posts методом GET
Плейсхолдеры в шаблонах
Как было раньше до 1.22. Если требуется обработать пути вида /posts/{id}, то используют слеш на конце пути. Например, при указании шаблона "/posts/" все пути, начинающиеся на /posts/ обрабатываются с помощью одного обработчика. Из-за этого в обработчике приходится отдельно вытаскивать id поста.
m.Handle("/posts/", h("posts-with-slash"))
1) Вызовем /posts/1: curl localhost:7777/posts/1
Вывод: posts-with-slash: Вы вызвали /posts/1 методом GET
2) Вызовем /posts/latest: curl localhost:7777/posts/latest
Вывод: posts-with-slash: Вы вызвали /posts/latest методом GET
/posts/1 и /posts/latest, скорее всего, должны отдавать разный контент, но оба пути используют один обработчик posts-with-slash.
Как теперь в 1.22. Можно использовать плейсхолдеры в шаблоне запроса для более точного роутинга. Например, /posts/{id} будет соответствовать всем URL, которые начинаются на /posts/ и содержат два сегмента.
m.Handle("GET /posts/{id}", h("id"))
m.Handle("GET /posts/latest", ("latest"))
1) Вызовем /posts/1: curl localhost:7777/posts/1
Вывод: id: Вы вызвали /posts/1 методом GET
2) Вызовем /posts/latest: curl localhost:7777/posts/latest
Вывод: latest: Вы вызвали /posts/latest методом GET
А id поста можно легко вытащить таким образом:
idString := req.PathValue("id")
Плейсхолдер может соответствовать целому сегменту, как {id} в примере выше, или, если он заканчивается на ..., — всем оставшимся сегментам пути, как в шаблоне /files/{pathname...}.
Для обозначения конца пути можно использовать специальный знак {$}. Например, /posts/{$} будет соответствовать только /posts/, но не /posts или /posts/123/.
Приоритет шаблонов
В Go допустим конфликт шаблонов. Например, шаблоны /posts/{id} и /posts/latest перекрывают друг друга. При вызове /posts/latest непонятно, какой обработчик нужно использовать. Давайте разберёмся, какие шаблоны имеют наивысший приоритет.
В версиях до 1.22 выбирается более длинный шаблон — независимо от их порядка. Например, Go предпочтёт /posts/latest, а не /posts/.
Теперь в 1.22 при конфликтах выбирается наиболее конкретный шаблон. Например, Go выберет /posts/latest вместо /posts/{id}. А вместо /users/{u}/posts/{id} выберет /users/{u}/posts/latest.
Для методов — аналогично. Например, GET /posts/{id} имеет приоритет над /posts/{id}, потому что первый соответствует только запросам GET и HEAD, а второй — запросам с любым методом, то есть такой шаблон менее конкретный.
Для хостов — по-другому. Для них пришлось сделать исключение, чтобы сохранить совместимость. Если два шаблона конфликтуют, но у одного явно указан хост, а у другого — нет, то выбирается шаблон с хостом.
Если два шаблона конфликтуют, но среди них нельзя выделить наиболее конкретный, вызовется паника. Например, /posts/latest подходит под шаблоны /posts/{id} и /{resource}/latest. В каком бы порядке вы ни зарегистрировали эти шаблоны, при регистрации в обработчике /posts/latest произойдёт паника. То есть до запуска сервера с таким роутингом дело не дойдёт.
Обратная совместимость
Изменения в роутинге ломают обратную совместимость. Например, предыдущие версии Go принимали шаблоны с фигурными скобками и трактовали их буквально, а в версии 1.22 используются фигурные скобки для подстановочных знаков.
Старое поведение можно вернуть, задав GODEBUG-переменную окружения: GODEBUG=httpmuxgo121=1.
Кроме того, проверьте, какая версия Go установлена у вас в go.mod. Если там версия ниже 1.22, то весь роутинг будет работать в режиме совместимости, и все нововведения будут отключены. Поднять версию в go.mod можно просто отредактировав его руками, или командой go mod edit -go=1.22.2 (укажите вашу версию Go).
Полезные материалы
- Релиз Go 1.22 (блог Go)
- Про новый роутинг в Go (блог Go)
- Документация ServeMux (официальная документация)
- Обратная совместимость в Go (блог Go)
Разбираемся в новом роутинге в Go 1.22
В начале февраля 2024 года вышел Go 1.22 . Вот, что нового и интересного принёс новый релиз: сделали более безопасное поведение переменных в циклах, добавили функции-итераторы в качестве...
habr.com