Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров7.9K

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

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

Если вам интересен процесс и вы хотите следить за дальнейшими материалами, буду признателен за подписку на мой телеграмм-канал. Там я публикую полезныe материалы по разработке, 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 является мощным инструментом, он имеет ограничения, например, механизм для применения 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 в обновлении go 1.22 получил возможность использовать динамические пути, но мы сделаем свой вариант, типа как в gin. Если интересно почитать про динамические пути в базовом маршрутизаторе, то вот: https://tip.golang.org/doc/go1.22#enhanced_routing_patterns

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

/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, объект контекста и даже группировку маршрутов. Хотя наш фреймворк не претендует на продакшн-уровень, он отлично подходит как учебный инструмент и основа для экспериментов.

Теги:
Хабы:
+8
Комментарии5

Публикации

Работа

Go разработчик
78 вакансий

Ближайшие события