В данной статье представлен пошаговый процесс разработки легковесного веб-фреймворка на языке программирования 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]
}
Разъяснение:
HandlerFunc: Мы определяем новый тип функции-обработчика, который принимает наш Context вместо стандартных http.ResponseWriter и http.Request. Это позволяет нам передавать больше информации в обработчик и предоставлять удобные методы через Context.
Context: Эта структура служит контейнером. Она содержит исходные Writer и Request для взаимодействия с базовым HTTP-сервером. Важно, что она также включает поле Params типа map[string]string для хранения значений, извлеченных из динамических сегментов URL (например, для пути /users/:id, здесь будет храниться {"id": "123"}).
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)
}
Что здесь происходит?
При регистрации маршрута передаётся:
pattern
— путь с возможными параметрами (/users/:id
);handler
— обработчик, принимающий наш собственныйContext
.
Метод
wrapWithMiddleware(...)
:применяет цепочку middleware;
проверяет соответствие HTTP-метода;
возвращает финальную функцию
http.HandlerFunc
, пригодную дляServeMux
.
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 последовательно, обрабатывая запрос, прежде чем передать его в основной обработчик.
Изначально
finalHandler
— это просто тот обработчик, который был передан функции.Цикл:
Мы проходим по всем middleware в обратном порядке (от последнего к первому), создавая цепочку.
Каждый middleware «обворачивает» предыдущий обработчик, чтобы управлять его выполнением (например, логировать запросы, проверять авторизацию и т.д.).
Внутри цикла создаётся новая функция-обработчик, которая будет вызывать текущее middleware и передавать управление следующему обработчику.
Возвращаемая функция:
Эта функция будет выполняться, когда приходит 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
).Мы начинаем с того, что разбиваем как шаблон маршрута (
pattern
), так и сам путь (path
) на части.Мы проходим по частям шаблона маршрута. Если часть шаблона начинается с
:
, это значит, что это параметр (например,:id
).Если текущая часть шаблона является параметром, мы сохраняем соответствующую часть пути в мапу
params
, используя имя параметра как ключ (например,id: 42
).Возвращаем мапу с параметрами, извлечёнными из пути.
Как это работает в контексте маршрутов?
Когда приходит запрос с определённым методом и маршрутом (например, GET /users/42
), wrapWithMiddleware
обрабатывает его следующим образом:
Проверяется, соответствует ли метод запроса ожидаемому методу (например,
GET
).Применяются все middleware, обрабатывая запрос и дополняя контекст.
После выполнения всех 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
реализованы аналогично, и все они:
Объединяют
prefix
группы и локальныйpattern
, формируя полный путь.Оборачивают хендлер в middleware, используя
wrapWithMiddleware
.Регистрируют обработчик маршрута через
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, объект контекста и даже группировку маршрутов. Хотя наш фреймворк не претендует на продакшн-уровень, он отлично подходит как учебный инструмент и основа для экспериментов.