В начале февраля 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)
}

Теперь будем немного менять муксер и курлом дёргать разные пути.


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).


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


Комментарии (0)