Разбираемся в новом роутинге в Go 1.22

В начале февраля 2024 года вышел Go 1.22. Вот, что нового и интересного принёс новый релиз: сделали более безопасное поведение переменных в циклах, добавили функции-итераторы в качестве rangefunc-эксперимента и улучшили шаблоны роутинга. В этой статье я сфокусируюсь на последнем, самом долгожданном, для многих, обновлении — шаблонах http-роутинга.

Роутинг в Go — общая проблема, для решения которой уже построили кучу фреймворков, в этом GitHub-репозитории собраны лучшие. Google сама признаётся, что они вдохновлялись сторонними решениями и лучшее добавили в net/http.

С приходом Go 1.22 всё необходимое для роутинга из коробки умеет делать http.ServeMux: он различает HTTP-методы, хосты и домены, а также может шаблонизировать пути через плейсхолдеры.


5swlbu7g_o6t-eo3ch1ra3khwss.png

Разберём роутинг на примере блога

Давайте поднимем сервер на 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).


Полезные материалы


© Habrahabr.ru