Пишем web-фреймворк на Go: как работают современные web-фреймворки под капотом

от автора

В данной статье представлен пошаговый процесс разработки легковесного веб-фреймворка на языке программирования Go. Основываясь на стандартной библиотеке net/http, мы исследуем ключевые концепции, лежащие в основе современных Go-фреймворков, таких как Gin, Echo и тд.

Важно отметить: цель данной статьи — объяснение фундаментальных принципов, а не создание готового продакшн фреймворка. Представленный код служит исключительно для иллюстрации концепций, так как многие разработчики используют фреймворки «из коробки», не углубляясь в детали их внутреннего устройства.

Если вам интересен процесс и вы хотите следить за дальнейшими материалами, буду признателен за подписку на мой телеграмм-канал. Там я публикую полезны материалы по разработке, Go, советы как быть продуктивным и конечно же отборные мемы: https://t.me/nullPointerDotEXE.

Введение

Язык программирования Go завоевал значительную популярность в области веб-разработки благодаря своей производительности, простоте и встроенной поддержке конкурентности. Стандартный пакет net/http предоставляет надежную основу для создания HTTP-серверов. Однако, для разработки сложных веб-приложений часто используются фреймворки, такие как Gin, Echo, и тд, которые предлагают более высокий уровень абстракции, упрощая рутинные задачи: маршрутизацию запросов, обработку параметров, управление middleware, работу с шаблонами и т.д.

Несмотря на удобство использования готовых решений, понимание их внутреннего устройства критически важно для эффективной разработки, отладки и оптимизации приложений. Эта статья преследует цель демистифицировать работу современных Go веб-фреймворков путем создания собственного мини-фреймворка с нуля. Мы будем последовательно реализовывать основные компоненты, подробно объясняя каждый шаг и выбранные архитектурные подходы. Наша философия заключается в расширении возможностей net/http, а не в полной его замене, сохраняя фокус на ясности и понимании фундаментальных механик.

Глава 1: Фундаментальные Компоненты Веб-Фреймворка

Любой веб-фреймворк начинается с механизма, ответственного за сопоставление входящих HTTP-запросов с соответствующими функциями-обработчиками. Этот механизм называется маршрутизатором или мультиплексором (mux). Кроме того, для удобной работы с данными запроса и ответа, а также для передачи информации между различными частями фреймворка (например, middleware и обработчиком), вводится понятие Контекста.

1.1 Стандартный net/http как Основа

Пакет net/http предоставляет http.ServeMux — мультиплексор запросов, который сопоставляет URL пути с зарегистрированными обработчиками. Он использует интерфейс http.Handler, ключевым методом которого является ServeHTTP(http.ResponseWriter, *http.Request). Стандартный подход выглядит так:

package main  import ( "fmt" "log" "net/http" )  func main() { mux := http.NewServeMux() // Создаем стандартный мультиплексор  // Регистрируем обработчик для пути "/" mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, World!") })  // Регистрируем обработчик для пути "/about" mux.HandleFunc("/about", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "About Page") })  // Запускаем сервер на порту 8080 с нашим мультиплексором fmt.Println("Starting server on :8080") if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatal(err) } }

http.NewServeMux() создает экземпляр стандартного роутера. mux.HandleFunc() регистрирует функцию-обработчик для конкретного URL-пути. 

http.ListenAndServe() запускает HTTP-сервер, передавая ему созданный mux для обработки входящих запросов. Это отправная точка, которую мы будем расширять.

1.2 Пользовательские Обработчики и Контекст Запроса

Стандартная сигнатура http.HandlerFunc (func(http.ResponseWriter, *http.Request)) функциональна, но для фреймворка может быть неудобной. Например, сложно элегантно передавать извлеченные параметры URL или данные из middleware в конечный обработчик. Для решения этой проблемы вводится понятие Контекста.

Контекст инкапсулирует http.ResponseWriter и *http.Request, а также предоставляет дополнительные методы и поля для работы с запросом в рамках нашего фреймворка.

package framework   import "net/http"  // HandlerFunc определяет тип для функций-обработчиков нашего фреймворка. type HandlerFunc func(*Context)  type Context struct { Writer  http.ResponseWriter // Оригинальный ResponseWriter Request *http.Request       // Оригинальный Request Params  map[string]string   // мапа для хранения параметров URL (например, :id) }  // Param возвращает значение параметра URL по его имени. func (c *Context) Param(key string) string { return c.Params[key] }

Разъяснение:

  1. HandlerFunc: Мы определяем новый тип функции-обработчика, который принимает наш Context вместо стандартных http.ResponseWriter и http.Request. Это позволяет нам передавать больше информации в обработчик и предоставлять удобные методы через Context.

  2. Context: Эта структура служит контейнером. Она содержит исходные Writer и Request для взаимодействия с базовым HTTP-сервером. Важно, что она также включает поле Params типа map[string]string для хранения значений, извлеченных из динамических сегментов URL (например, для пути /users/:id, здесь будет храниться {«id»: «123»}).

  3. Param(key string): Мы добавляем вспомогательный метод к Context, который упрощает доступ к параметрам URL по их имени.

1.3 Создание Собственного Роутера (Мультиплексора)

Хотя http.ServeMux является мощным инструментом, он имеет ограничения, например, отсутствие встроенной поддержки динамических параметров URL (вида /users/:id) и механизма для применения middleware к группам маршрутов. Кроме того, в более продвинутых фреймворках, таких как Gin, Echo или Fiber, для маршрутизации используется дерево (обычно радиальное дерево, aka radix tree), что обеспечивает высокую скорость поиска маршрута и поддержку вложенных параметров. В нашей реализации мы не будем сразу строить дерево, а сосредоточимся на пошаговом расширении возможностей, создавая свою структуру роутера, встроив в нее стандартный http.ServeMux.

type router struct { http.ServeMux             // Встраиваем стандартный mux middleware []MiddlewareFunc }  func NewRouter() *router { return &router{} }

Зачем встраивать http.ServeMux?

ServeMux уже отлично справляется с маршрутизацией по строковому шаблону. Мы просто добавляем над ним абстракции:

  • поддержку динамических сегментов (:id);

  • расширяемый Context;

  • middleware;

  • маршруты, привязанные к HTTP-методам.

Таким образом, мы не изобретаем велосипед, а лишь дополняем и расширяем стандартный функционал.

1.4 Реализации методов GET, POST, PUT , DELETE

func (r *router) GET(pattern string, handler HandlerFunc) { final := r.wrapWithMiddleware(handler, pattern, http.MethodGet) r.ServeMux.HandleFunc(cleanPattern(pattern), final) }  func (r *router) POST(pattern string, handler HandlerFunc) { final := r.wrapWithMiddleware(handler, pattern, http.MethodPost) r.ServeMux.HandleFunc(cleanPattern(pattern), final) }  func (r *router) PUT(pattern string, handler HandlerFunc) { final := r.wrapWithMiddleware(handler, pattern, http.MethodPut) r.ServeMux.HandleFunc(cleanPattern(pattern), final) }  func (r *router) DELETE(pattern string, handler HandlerFunc) { final := r.wrapWithMiddleware(handler, pattern, http.MethodDelete) r.ServeMux.HandleFunc(cleanPattern(pattern), final) } 

Что здесь происходит?

  1. При регистрации маршрута передаётся:

    • pattern — путь с возможными параметрами (/users/:id);

    • handler — обработчик, принимающий наш собственный Context.

  2. Метод wrapWithMiddleware(...):

    • применяет цепочку middleware;

    • проверяет соответствие HTTP-метода;

    • возвращает финальную функцию http.HandlerFunc, пригодную для ServeMux.

  3. cleanPattern(pattern) очищает путь от динамических сегментов (:id) перед регистрацией маршрута.

Как работает cleanPattern?

Стандартный ServeMux не поддерживает маршруты с переменными параметрами — он требует чётких, статических путей. Поэтому перед регистрацией маршрута мы заменяем переменные на пустые строки, чтобы путь стал «безопасным» для регистрации.

/users/:id → /users/

Пример:

func cleanPattern(pattern string) string { parts := strings.Split(strings.Trim(pattern, "/"), "/") for i, part := range parts { if strings.HasPrefix(part, ":") { parts[i] = "" // убираем переменные сегменты } } return "/" + strings.Join(parts, "/") }

Этот трюк позволяет использовать ServeMux для регистрации путей с параметрами, которые мы затем парсим вручную на этапе обработки запроса (через parseParams).

1.5 Реализация цепочки middleware

Для расширения функциональности маршрутов (например, добавления логирования, авторизации или обработки ошибок), в нашем роутере реализуется механизм middleware — функций, которые могут обрабатывать запрос до или после основного обработчика.

Тип MiddlewareFunc

type MiddlewareFunc func(*Context, HandlerFunc)

Каждое middleware — это функция, принимающая:

  • *Context — расширенный контекст, содержащий ResponseWriter, Request, параметры из URL и любые другие данные;

  • HandlerFunc — следующий обработчик по цепочке (включая следующий middleware или основной хендлер).

Такая сигнатура позволяет middleware модифицировать поведение запроса, логировать, делать валидацию, и даже прерывать цепочку вызовов (например, при ошибке авторизации не вызывать next(ctx)).

Подключение Middleware

func (r *router) Use(mw ...MiddlewareFunc) { r.middleware = append(r.middleware, mw...) }

Метод Use(...) позволяет добавлять одно или несколько middleware в роутер. Они применяются ко всем маршрутам.

Порядок важен: middleware применяются в порядке подключения, но оборачиваются в обратном порядке (см. ниже).

wrapWithMiddleware — цепочка вызовов

func (r *router) wrapWithMiddleware(handler HandlerFunc, pattern string, method string) http.HandlerFunc { // Оборачиваем handler через middleware (обратно) finalHandler := handler for i := len(r.middleware) - 1; i >= 0; i-- { mw := r.middleware[i] next := finalHandler  finalHandler = func(ctx *Context) { mw(ctx, next) } }  // Финальный адаптер — превращает HandlerFunc в http.HandlerFunc return func(w http.ResponseWriter, r *http.Request) { if r.Method != method { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return }  params := parseParams(pattern, r.URL.Path)  ctx := &Context{ Writer:  w, Request: r, Params:  params, }  finalHandler(ctx) } }

Как работает:

Эта функция оборачивает обработчик в цепочку middleware. Цель — выполнить middleware последовательно, обрабатывая запрос, прежде чем передать его в основной обработчик.

  1. Изначально finalHandler — это просто тот обработчик, который был передан функции.

  2. Цикл:

    • Мы проходим по всем middleware в обратном порядке (от последнего к первому), создавая цепочку.

    • Каждый middleware «обворачивает» предыдущий обработчик, чтобы управлять его выполнением (например, логировать запросы, проверять авторизацию и т.д.).

    • Внутри цикла создаётся новая функция-обработчик, которая будет вызывать текущее middleware и передавать управление следующему обработчику.

  3. Возвращаемая функция:

    • Эта функция будет выполняться, когда приходит HTTP-запрос. Она проверяет метод запроса, и если метод не совпадает с ожидаемым, возвращает ошибку «Method Not Allowed».

    • Создаётся новый Context, в который помещаются данные запроса (Request), ответ (Writer) и параметры URL.

    • Затем вызывается финальный обработчик, который был собран через цепочку middleware.

Пояснение по цепочке middleware:

  • Важно понимать, что каждый middleware может изменять контекст или запрос (например, добавлять к нему какие-то данные, выполнять проверки, логировать запросы и т.д.).

  • Цепочка middleware строится таким образом, что обработка запроса происходит от последнего middleware к первому. То есть, каждое следующее middleware вызывается после выполнения предыдущего.

parseParams (Функция для извлечения параметров из пути)

func parseParams(pattern, path string) map[string]string {   // Разделяем оба пути (шаблон и фактический путь) на части по "/". patternParts := strings.Split(strings.Trim(pattern, "/"), "/") pathParts := strings.Split(strings.Trim(path, "/"), "/")  params := map[string]string{}  for i := range patternParts {       // Если часть шаблона начинается с ":", значит это параметр. if strings.HasPrefix(patternParts[i], ":") {           // Извлекаем имя параметра, убирая двоеточие. key := strings.TrimPrefix(patternParts[i], ":")           // Сохраняем значение параметра, если оно есть в пути. if i < len(pathParts) { params[key] = pathParts[i] } } } // Возвращаем все найденные параметры. return params }

Объяснение:

  • Функция parseParams извлекает параметры из пути URL. Она используется для того, чтобы динамически обрабатывать URL-структуры, в которых могут быть переменные (например, /users/:id).

    1. Мы начинаем с того, что разбиваем как шаблон маршрута (pattern), так и сам путь (path) на части.

    2. Мы проходим по частям шаблона маршрута. Если часть шаблона начинается с :, это значит, что это параметр (например, :id).

    3. Если текущая часть шаблона является параметром, мы сохраняем соответствующую часть пути в мапу params, используя имя параметра как ключ (например, id: 42).

    4. Возвращаем мапу с параметрами, извлечёнными из пути.

Как это работает в контексте маршрутов?

Когда приходит запрос с определённым методом и маршрутом (например, GET /users/42), wrapWithMiddleware обрабатывает его следующим образом:

  1. Проверяется, соответствует ли метод запроса ожидаемому методу (например, GET).

  2. Применяются все middleware, обрабатывая запрос и дополняя контекст.

  3. После выполнения всех middleware, вызывается финальный обработчик, который может извлечь параметры из URL и передать их дальше в логику обработки.

1.6 Группировка маршрутов

Разделение маршрутов на группы с префиксами и специфичными middleware — это мощный паттерн, заимствованный из архитектуры современных веб-фреймворков. Он позволяет логически структурировать связанные маршруты и повторно использовать мидлваре обработчики, повышая читаемость и модульность кода. Рассмотрим реализацию такого механизма, которая представлена через тип routerGroup.

Структура routerGroup

type routerGroup struct { prefix     string // Общий префикс маршрутов, входящих в эту группу middleware []MiddlewareFunc parent     *router // Ссылка на корневой маршрутизатор (router) }

Структура routerGroup используется для хранения информации о конкретной группе маршрутов. Каждый маршрут в пределах группы будет автоматически наследовать общий префикс prefix, а также специфичный стек middleware. Это удобно для объединения маршрутов, относящихся к одному ресурсу или модулю API, например, /api/users или /admin/panel.

Метод Group

func (r *router) Group(prefix string, newMiddleware ...MiddlewareFunc) *routerGroup { newGroup := routerGroup{} if len(newMiddleware) != 0 { newGroup.middleware = append(newGroup.middleware, newMiddleware...) } newGroup.prefix = prefix newGroup.parent = r return &newGroup }

Метод Group создает новую группу маршрутов, принимая префикс и необязательные middleware. Созданная группа содержит ссылку на своего «родителя» — главный маршрутизатор, что обеспечивает правильную регистрацию всех маршрутов через базовую реализацию.

Методы регистрации маршрутов

func (r *routerGroup) GET(pattern string, handler HandlerFunc) { final := r.wrapWithMiddleware(handler, pattern, http.MethodGet) fullPattern := r.prefix + pattern r.parent.HandleFunc(cleanPattern(fullPattern), final) }

Методы GET, POST, PUT и DELETE реализованы аналогично, и все они:

  1. Объединяют prefix группы и локальный pattern, формируя полный путь.

  2. Оборачивают хендлер в middleware, используя wrapWithMiddleware.

  3. Регистрируют обработчик маршрута через parent.HandleFunc.

Это обеспечивает, что конечная логика маршрута будет корректно задействована и с учетом группы, и с учетом глобального контекста.

Оборачивание middleware

Тут ситуация аналогичная и с оборачивателем router, но тут мы будем оборачивать как router, так и routerGroup

func (r *routerGroup) wrapWithMiddleware(handler HandlerFunc, pattern string, method string) http.HandlerFunc { fullPattern := r.prefix + pattern finalHandler := handler  // Оборачиваем в middleware, специфичные для группы for i := len(r.middleware) - 1; i >= 0; i-- { mw := r.middleware[i] next := finalHandler finalHandler = func(ctx *Context) { mw(ctx, next) } }  // Затем оборачиваем в middleware маршрутизатора (глобальные) for i := len(r.parent.middleware) - 1; i >= 0; i-- { mw := r.parent.middleware[i] next := finalHandler finalHandler = func(ctx *Context) { mw(ctx, next) } }  return func(w http.ResponseWriter, r *http.Request) { // Проверка метода запроса if r.Method != method { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } params := parseParams(fullPattern, r.URL.Path) ctx := &Context{ Writer:  w, Request: r, Params:  params, } // Выполняем цепочку middleware + хендлер finalHandler(ctx) } }

Функция wrapWithMiddleware выполняет ключевую задачу: она формирует цепочку вызовов middleware, при этом сначала применяются middleware, привязанные к группе, а затем — глобальные middleware маршрутизатора. Это дает возможность иерархически выстраивать обработку запроса, как это делается в большинстве современных фреймворков (например, в Echo, Gin или Express).

Использование

Напишем простой код для теста:

package main  import ( frm "app/internal/http" //наш фреймворк "fmt" "log" "net/http" )  // Глобальный middleware для логирования каждого запроса func Logger(c *frm.Context, next frm.HandlerFunc) { log.Printf("[LOG] %s %s\n", c.Request.Method, c.Request.URL.Path) next(c) }  // Middleware группы — простая проверка заголовка Authorization func authMiddleware(c *frm.Context, next frm.HandlerFunc) { if c.Request.Header.Get("Authorization") == "" { http.Error(c.Writer, "Unauthorized", http.StatusUnauthorized) return } next(c) }  func main() { // Инициализация маршрутизатора r := frm.NewRouter()  // Подключение глобального middleware r.Use(Logger)  //чисто для теста r.GET("/ping", func(c *frm.Context) { fmt.Fprintf(c.Writer, "pong") })  // Группа маршрутов с префиксом /api/v1 и middleware авторизации api := r.Group("/api/v1", authMiddleware) { // GET-запрос с параметром id api.GET("/users/:id", func(c *frm.Context) { userID := c.Param("id") fmt.Fprintf(c.Writer, "User ID: %s", userID) })  // POST-запрос для создания пользователя (пример) api.POST("/auth", func(c *frm.Context) { // В реальности здесь была бы обработка тела запроса fmt.Fprint(c.Writer, "User created") })  }  log.Println("Server running at :8081") http.ListenAndServe(":8081", r) }

Заключение

Создание собственного веб-фреймворка — это не только интересное упражнение, но и способ глубоко понять, как работают популярные решения, такие как Gin или Echo. Мы увидели, как на основе стандартной библиотеки Go можно реализовать маршрутизацию, middleware, объект контекста и даже группировку маршрутов. Хотя наш фреймворк не претендует на продакшн-уровень, он отлично подходит как учебный инструмент и основа для экспериментов.


ссылка на оригинал статьи https://habr.com/ru/articles/902496/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *