[Перевод] Обработка ошибок в web apps не должна быть такой сложной

f352b6c8631a0ce9cb2717eeb1230f4b.jpg

Зачем?

В процессе работы с Go я столкнулся с ещё одной проблемой — обработкой ошибок в хендлерах. Как можно сделать этот процесс удобным и эффективным для 150+ обработчиков? Как обеспечить консистентность и поддержку при обработке ошибок?

Разумеется, мои поиски палочки-выручалочки закончились ничем. Есть протокол RFS7807, есть библиотека, есть масса противоречивых статей — выбирай, но как будто чего-то не хватает… По существу статья ниже полностью не отвечает на мои вопросы, но в ней достаточно много примеров, и я думаю, она будет полезной. Это лишь повод/предложение поделиться вашими примерами успешных кейсов.

Собственно, статья, приятного прочтения.

Error handling in Go web apps shouldn’t be so awkward

январь 2024.

Полезный патерн обработки ошибок для REST, gRPC, и остальных сервисов.

Я собираюсь описать алгоритм обработки ошибок, который показался мне довольно элегантным при написании REST, gRPC или других сервисов на GO.
При написании этого поста я преследовал три цели:

  1. Объяснить паттерн, который я внедрил для нескольких клиентов, другим разработчикам, работающим со схожей кодовой базой;

  2. Продемонстрировать другим свой паттерн, чтобы они могли применить его в своих приложениях;

  3. Получить фидбек. Это лучший паттерн, который вы видели? Есть ли в нем слабые стороны, которые можно улучшить?

Для простоты все примеры будут частью REST API использующего HTTP код состояния. Но те же принципы могут быть использованы для gRPC или даже для CLI.

Проблема

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

Давайте посмотрим на простой HTTP хендлер, использующий шаблон HandlerFunc стандартной библиотеки, который извлекает виджет из БД и возвращает его в формате JSON.

func (s *Service) GetWidget(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	id, err := strconv.Atoi(r.Form.Get("widget_id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	widget, err := s.db.GetWidget(id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			http.Error(w, err.Error(), http.StatusNotFound)
			return
		}
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	widgetJSON, err := json.Marshal(widget)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header.Set("Content-Type", "application/json")
	w.Write(widgetJSON)

Хоть это и должен быть более менее реалистичный пример, он все равно немного упрощен в сравнении с тем, что я обычно нахожу на проде. В частности, я никогда не видел, чтобы http.error использовали в реальных сервисах. Вероятно, в вашем случае будет использоваться пользовательский формат ошибки, который позволит отобразить именно то, что вам нужно. Возможно, вы используете JSON-ответ с необходимым контекстом или определяете собственные коды ошибок, и так далее. Или, может быть, вы хотите отобразить ошибку в формате HTML. Каким бы ни был ваш вариант, я предполагаю, что в вашем приложении вы заменяете стандартную функцию http.Error() на более сложную. Это, вероятно, означает, что ваш код ещё более сложный и повторяющийся, чем пример, который я привёл выше.

Кроме того позвольте мне указать на несколько проблем, которые я вижу в коде выше:

  • В языке Go существует идиома, которая позволяет удобно обрабатывать ошибки: «if err!= nil {return err}». Однако в данном случае мы не можем воспользоваться этой идиомой, так как сигнатура HandlerFunc не предусматривает возврат ошибки. Вместо этого нам нужно для каждой ошибки: а) обработать её; б) отдельно вернуть результат.

  • Мы должны явно обрабатывать статус HTTP для каждой ошибки. Если у вас есть десятки или даже сотни обработчиков, это может стать проблемой. Здесь не применяется принцип DRY (Don’t Repeat Yourself). В одном хендлере это не так критично. Но было бы неплохо иметь стандартный HTTP-статус для всех ошибок, например, 500 / Internal Server Error.

  • Этот обработчик должен беспокоиться о внутренних механизмах базы данных. В частности, он проверяет, получили ли мы ошибку sql.ErrNoRows. Обработчик HTTP не должен знать о бд. Это плохая тесная связанность, от которой мы можем избавиться.

А что, если вместо этого…

А что, если вместо этого:

  • для каждой ошибки мы могли бы просто `return err`, и все бы автоматически обработалось? Ошибка была бы отрендерена в правильном формате и отправлена пользователю?

  • магия, отвечающая за отображение ошибки, также знала бы правильный HTTP-статус ? 400 invalid input, 404 not found, 401 unauthorized access и так далее?

  • хранилище данных, будь то база данных SQL, MongoDB или файловая система, просто сообщало бы нам «эта ошибка означает, что не найдено», и это могло бы автоматически преобразоваться в 404, вместо того чтобы обработчику были известны детали реализации?

    Шаблон, который я собираюсь описать, дает нам все эти возможности. Более того, он позволяет использовать ряд других довольно мощных шаблонов. Я упомяну некоторые из них в конце, и, возможно, позже я напишу более подробно о некоторых из них (дайте знать, если вас это заинтересует)

Идиоматическая обработка ошибок

Три описанных мной типа поведения, которых мы хотим достичь, зависят от двух факторов. Первый из них — это «идиоматическая обработка ошибок». Нам необходимо иметь возможность просто возвращать ошибку в наших обработчиках. К сожалению, стандартная библиотека не поддерживает эту возможность. Однако некоторые сторонние фреймворки предоставляют такую функциональность. Самый популярный, с которым я знаком, — это labstack echo, чей HandlerFunc выглядит следующим образом:

type HandlerFunc func(c Context) error

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

// customHandler преобразует обработчик, возвращающий ошибку, в стандартный http.HandlerFunc.
func customHandler(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		err := f(w, r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError) // Погодите, только 500? Подробнее позже
		}
	}
}

С такой функцией адаптера наш предыдущий обработчик упрощается до:

func (s *Service) GetWidget(w http.ResponseWriter, r *http.Request) error {
	if err := r.ParseForm(); err != nil {
		return err
	}
	id, err := strconv.Atoi(r.Form.Get("widget_id"))
	if err != nil {
		return err
	}
	widget, err := s.db.GetWidget(id)
	if err != nil {
		return err
	}
	widgetJSON, err := json.Marshal(widget)
	if err != nil {
		return err
	}
	w.Header.Set("Content-Type", "application/json")
	w.Write(widgetJSON)
}

Разумеется, это означает, что мы должны применить функцию адаптера при настройке маршрутов.

mux.Handle("/widget", customHandler(s.GetWidget))

Конечно, мы также повлияли на работу этого эндпоинта. Теперь все ошибки классифицируются как 500. Мы обсудим это подробнее.

Но сначала эксперимент, над которым я работаю

Прежде чем мы начнем, я хочу упомянуть экспериментальную библиотеку, над которой я работаю, с надеждой, что она в конечном итоге может стать официальным предложением для стандартной библиотеки (хотя я считаю, что шансы на ее принятие невелики), чтобы расширить определение типа http.HandlerFunc и включить в него необязательное возвращаемое значение ошибки. Библиотека называется gitlab.com/flimzy/httpe, и она добавляет варианты WithError к http.Handler, http.HandlerFunc, ServeHTTP и связанным промежуточным обработчикам. Она основана на моем опыте работы с клиентами в течение многих лет, но теперь существует как самостоятельная библиотека для удобного включения, если вы захотите.

Если вы решите использовать эту библиотеку, новая версия обработчика останется неизменной, но вместо вызова customHandler вы можете использовать:

import "gitlab.com/flimzy/httpe"

mux.Handle("/widget", httpe.ToHandler(s.GetWidget))

Основное преимущество использования библиотеки HTTPe перед созданием собственного кастомного хендлера заключается в том, что она предлагает поддержку мидлваров и позволяет смешивать стандартные хендлеры с хендлерами, поддерживающими ошибки. Это даёт возможность обрабатывать ошибки скрытым образом. Однако эта тема выходит за рамки данного обсуждения.

Как обрабатывать различные HTTP-статусы

Второй важный фактор, влияющий на улучшения, — это способ указания HTTP-статуса. Хотя новый шаблон хендлера упрощает обработку ошибок, он может нарушить её, обрабатывая все ошибки как ошибки с кодом 500 (Внутренняя ошибка сервера) или любым другим произвольным статусом, который вы установили в своей функции customHandler. Давайте исправим это.

Ошибки — это интерфейсы

Вспомним, что в Go тип ошибки — это тип интерфейса, определяемый как:

type error interface {
	Error() string
}

Это мощный инструмент, так как он позволяет нам создавать собственные типы ошибок. Мы можем расширить тип ошибки, чтобы добавить в него дополнительные методы в соответствии с нашими потребностями. Мы планируем воспользоваться этими возможностями и создать собственный тип ошибок, который будет включать HTTP-статус. Кроме того, мы хотим добавить метод для его извлечения. Для этого мы будем использовать простой пользовательский тип:

type statusError struct {
	error
	status int
}

Теперь это уже «полный» тип ошибки. Он уже удовлетворяет интерфейсу ошибки благодаря встраиванию типа error (таким образом, его методы становятся методами нашего типа). И он включает код состояния. Но нам нужно добавить еще несколько элементов, чтобы сделать его полным. Сначала давайте добавим метод Unwrap, чтобы позволить работать правильно функциям errors.Unwrap и связанным errors.Is и errors.As:

func (e statusError) Unwrap() error {
	return e.error
}

Теперь мы также хотим добавить метод для получения статуса. Строго говоря, это не обязательно. Вы можете получить доступ к коду состояния, приведя ошибку обратно к типу statusError с помощью утверждения типа или с использованием функций errors.Is или errors.As. Однако это довольно громоздко и требует экспорта поля (если ваше приложение не находится в единственном пакете — я надеюсь, это не так!). К тому же, если мы предоставим статус через метод интерфейса, то сможем использовать несколько реализаций нашего пользовательского типа ошибки —, а это я почти всегда делаю. Давайте добавим эту деталь.

func (e statusError) HTTPStatus() int {
	return e.status
}


Теперь вы можете выбрать любое название для своего метода. Я, например, выбрал название HTTPStatus, поскольку оно менее двусмысленно и при этом достаточно короткое, чтобы не раздражать. Вы также можете использовать любое другое название или несколько названий. Например, если вы создаёте службу JSON-RPC, вам может понадобиться метод JSONRPCStatus(). А если вы создаёте службу gRPC, у вас уже есть определённый интерфейс: GRPCStatus().

Используем наш кастомный тип

Теперь, когда у нас есть наш тип statusError, давайте внедрим его в наш обработчик, чтобы исправить обработку кода состояния:

func (s *Service) GetWidget(w http.ResponseWriter, r *http.Request) error {
	if err := r.ParseForm(); err != nil {
		return statusError{error: err, status: http.StatusBadRequest}
	}
	id, err := strconv.Atoi(r.Form.Get("widget_id"))
	if err != nil {
		return statusError{error: err, status: http.StatusBadRequest}
	}
	widget, err := s.db.GetWidget(id)
	if err != nil {
		return statusError{error: err, status: http.StatusInternalServerError}
	}
	widgetJSON, err := json.Marshal(widget)
	if err != nil {
		return statusError{error: err, status: http.StatusInternalServerError}
	}
	w.Header.Set("Content-Type", "application/json")
	w.Write(widgetJSON)
}

Итак, мы в основном закончили с обработкой кодов состояния. Единственное, что осталось, — это вызов базы данных. Сейчас мы обрабатываем все ошибки как статус 500, хотя ошибки, связанные с отсутствием виджета, должны обрабатываться как 404. Чтобы решить эту проблему, нам нужно, чтобы наш уровень доступа к данным знал о новых типах ошибок и обрабатывал их соответствующим образом.

func (db *DB) GetWidget(id int) (*Widget, error) {
	widget, err := db.Get(/* ... */)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, statusError{error: err, status: http.StatusNotFound}
	}
	if err != nil {
		return nil, statusError{error: err, status: http.StatusInternalServerError}
	}
	return widget, nil
}

Подход, когда слой БД знает о коде состояния HTTP и конкретных обработчиках, может снизить модульность и создать нежелательные зависимости между компонентами приложения. ИМХО

И последнее: нам нужно обновить наш customHandler, чтобы обработать этот новый тип ошибки:

// customHandler преобразует обработчик, возвращающий ошибку, в стандарт http.HandlerFunc.
func customHandler(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		err := f(w, r)
		if err != nil {
			var status int
			var statusErr interface {
				error
				HTTPStatus() int
			}
			if errors.As(err, &statusErr) {
				status = statusErr.HTTPStatus()
			}
			http.Error(w, err.Error(), status)
		}
	}
}

Итак, теперь мы вернулись к использованию полнофункционального обработчика виджетов. Кроме того, мы разделили нашу логику работы с БД и наш хендлер. Это можно считать победой.

Дальнейшие улучшения

Наш хендлер всё ещё имеет не самый аккуратный вид из-за большого количества повторяющихся структур CustomError{}. У нас также есть как наш обработчик, так и уровень доступа к данным, зависящий от конкретного типа statusError  И даже несмотря на то, что этот уровень не экспортируется, он всё равно находится в одном пакете с нашим хендлером. Это не совсем то, чего мы хотим. Давайте создадим отдельный пакет для нашего пользовательского типа ошибки и добавим в него удобную и понятную функцию-конструктор.

package apperr // Используйте описательное название

type statusError struct {
	error
	status int
}

func (e statusError) Unwrap() error   { return e.error }
func (e statusError) HTTPStatus() int { return e.status }

func WithHTTPStatus(err error, status int) error {
	return statusError{
		error: err,
		status: int,
	}
}

Теперь наш хендлер может быть обновлен до чуть более читаемого:

func (s *Service) GetWidget(w http.ResponseWriter, r *http.Request) error {
	if err := r.ParseForm(); err != nil {
		return apperr.WithStatus(err, http.StatusBadRequest)
	}
	id, err := strconv.Atoi(r.Form.Get("widget_id"))
	if err != nil {
		return apperr.WithStatus(err, http.StatusBadRequest)
	}
	widget, err := s.db.GetWidget(id)
	if err != nil {
		return err // база данных уже установила для нас соответствующий код состояния
	}
	widgetJSON, err := json.Marshal(widget)
	if err != nil {
		return apperr.WithStatus(err, http.StatusBadRequest)
	}
	w.Header.Set("Content-Type", "application/json")
	w.Write(widgetJSON)
}

Установка дефолтного статуса

Мы можем внести еще одно существенное улучшение: установить статус по умолчанию.

На самом деле, вы, возможно, заметили, что наша улучшенная функция customHandler не имеет статуса по умолчанию. Это означает, что если мы передадим ему сообщение об ошибке, не содержащее статуса HTTP, он попытается отправить ответ со статусом 0. Вероятно, нам это не нужно.

Давайте решим эту проблему, добавив вспомогательную функцию в наш пакет apperr, которую также можно использовать из других мест:

package apperr

// HTTP Status возвращает статус HTTP, включенный в err. Если значение err равно нулю, эта
// функция возвращает 0. Если значение err не равно нулю и не содержит HTTP-статуса,
// значение по умолчанию [net/http.StatusInternalServerError].
func HTTPStatus(err error) int {
	if err == nil {
	return 0
	}
	var statusErr interface {
		error
		HTTPStatus() int
	}
	if errors.As(err, &statusErr) {
		return statusErr.HTTPStatus()
	}
	return http.StatusInternalServerError
}

С помощью этой новой функции наш пользовательский хендлер может быть упрощен и улучшен:

func customHandler(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		err := f(w, r)
		if err != nil {
			http.Error(w, err.Error(), apperr.HTTPStatus(err))
		}
	}
}

И наш хендлер также можно улучшить, исключив вызов apierr.WithStatus, когда мы хотим, чтобы по умолчанию было 500.

Дальнейшие улучшения

На самом деле это лишь начало того, что можно получить, используя стандартное расширение для обработки ошибок всего приложения и соответствующие обработчики.

Следующее, что я обычно улучшаю в приложениях, где я реализую этот шаблон, — это добавляю пару стандартных мидлваров:

Логирование мидлваре

Добавление промежуточного ПО (middleware) для логгирования ошибок, при необходимости, является отличным дополнением. Это также устраняет «необходимость» в регистрации ошибки каждый раз, когда она происходит — просто передайте ее вызывающему, и позвольте промежуточному ПО залогировать ее за вас. Вот упрощенный пример, использующий сигнатуры функций в gitlab.com/flimzy/httpe:

func loggingMiddleware(logger *slog.Logger) func(next httpe.HandlerWithError) httpe.HandlerWithError {
	return httpe.HandlerWithErrorFunc(func (w http.ResponseWriter, r *http.Request) error {
		err := next.ServeHTTPWithError(w, r)
		status := http.StatusOK
		if err != nil {
			status = apperr.HTTPStatus(err)
			logger.Error("request served with error", "status", status, "request", r, "error", err)
		} else {
			logger.Info("request served", "status", status, "request", r)
		}
	})
}

Имейте в виду, что хендлер все еще может вызывать w.WriteHeader со статусом, отличным от того, который содержится в error (или даже при отсутствии ошибки). Таким образом, надежная реализация также проверит это.

Обработка ошибок с помощью мидлваре

Улучшением по сравнению с пользовательской функцией обработчика, показанной выше, является перенос сообщения об ошибке в мидлваре. Для этого требуется пакет httpe или аналогичная реализация, которая может работать с промежуточным ПО.

func serveErrors() func(next httpe.HandlerWithError) httpe.HandlerWithError {
	return httpe.HandlerWithErrorFunc(func (w http.ResponseWriter, r *http.Request) error {
		err := next.ServeHTTPWithError(w, r)
		if err != nil {
			http.Error(w, err.Error(), apperr.Status(err))
		}
	})
}

Panic-recovery middleware

Большинство веб-приложений имеют подобный функционал (или должны иметь!), но версия, которая работает с обработчиками, возвращающими ошибки, может быть удобной, поскольку ей нужно лишь преобразовать паники в ошибки, а не обрабатывать их напрямую:

func serveErrors() func(next httpe.HandlerWithError) httpe.HandlerWithError {
	return httpe.HandlerWithErrorFunc(func (w http.ResponseWriter, r *http.Request) (err error) {
		defer func() {
			if r := recover(); r != nil {
				switch t := r.(type) {
					case error:
						err = t
					default:
						err = fmt.Errorf("%v")
				}
			}
		}()
		return next.ServeHTTPWithError(w, r)
	})
}

Собственные коды ошибок

Создание собственных уникальных кодов ошибок, характерных для вашего домена, является более эффективным решением, чем использование HTTP-статусов для обозначения ошибок. В небольших веб-приложениях, возможно, достаточно ограничиться только HTTP-кодами состояния, но для большинства реальных приложений это не подходит. Всё остальное в этом шаблоне можно использовать с вашими собственными уникальными кодами ошибок. Просто убедитесь, что ваши собственные типы ошибок возвращают соответствующие HTTP-коды (или коды JSON-RPC, gRPC или что-либо ещё). После внесения таких изменений наш предыдущий метод работы с базой данных может выглядеть примерно так:

func (db *DB) GetWidget(id int) (*Widget, error) {
	widget, err := db.Get(/* ... */)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, apperror.ErrWidgetNotFound
	}
	if err != nil {
		return nil, err
	}
	return widget, nil
}

Тогда различные вызывающие функции могут проводить свой собственный анализ ошибок, по мере необходимости:

internalCode := apperror.Code(err) // Внутренний код ошибки

httpStatus := apperror.HTTPStatus(err) // HTTP-статус

Я оставляю точную реализацию apperror.ErrWidgetNotFound и связанных функций как упражнение для читателя.

Ограничения


Этот подход не панацея. Есть некоторые недостатки. Следует выделить несколько из них.

  1. Нестандартность. Очевидно. Переход от обработчика, возвращающего ошибку, к стандартному обработчику требует дополнительного кода. Хотя многие готовы пожертвовать некоторой избыточностью ради использования такого подхода, например, с помощью тяжеловесного фреймворка, предлагающего это преимущество (вместе с другими, конечно).

  2. Появление двух способов отправки ответов клиенту. Это может раздражать. Хотя на практике это обычно не вызывает больших проблем, но требует постоянного учета. Вы можете устанавливать HTTP-статус ответа либо вызовом w.WriteHeader(), либо возвращая ошибку. Очевидно, что каждый ответ может иметь только один статус. И если вы вызываете w.WriteHeader(), то он обычно имеет приоритет (если вы не реализовали свой собственный http.ResponseWriter с другим поведением).

  3. Некоторые поведения становятся неявными. Представленная функция apperr.HTTPStatus() возвращает стандартный статус для ошибок, не содержащих статус. Хотя это имеет смысл и может быть полезно, это немного «волшебно» и может удивить тех, кто не знаком с этим шаблоном. Также может запутать `apperr.WithStatus (err, http.StatusNotFound)`. Хотя при первом чтении должно быть ясно, что он включает HTTP-статус с ошибкой, неясно, какой другой код использует этот статус и как он используется. Конечно, цель этого поста — помочь решить этот недостаток.

Другие ограничения:

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

  • Отсутствие удобного способа указать нестандартный HTTP-статус (например, 201). Для этого все еще приходится использовать w.WriteHeader ().

  • Некоторые приложения могли бы эффективнее использовать функцию с сигнатурой типа «func (*http.Request) (any, error)», где ответ (вероятно, в формате JSON) является первым аргументом, возвращаемым функцией.

Что дальше?

Я упомянул в начале, что этот шаблон часто подвержен дополнительным улучшениям. Позвольте мне упомянуть несколько из них, не вдаваясь в подробности. Если вам нужно более подробное объяснение по какому-либо из них, дайте мне знать.

  • Включение дополнительных метаданных в ошибки. Стек вызовов — очевидный пример, который хорошо предоставляется, например, github.com/pkg/errors. Расширьте ваше промежуточное ПО для ведения журнала для извлечения стека вызовов и включения его в журналы.

  • Скрытие сообщений об ошибках для определенных HTTP-статусов. Я обычно пишу промежуточное ПО для обработки ошибок так, чтобы клиенту всегда возвращался просто «Внутренняя ошибка сервера» при наличии статуса 500, чтобы избежать риска возможного раскрытия конфиденциальной информации, что может произойти при некорректно обработанных ошибках. HTTP-статусы 401 и 403 также подходят для этого.

  • Подобно скрытию определенных ошибок, возможно, вам нужно предоставить пользователям вашего приложения удобочитаемую версию сообщения об ошибке, в то время как подробности ошибки, включенные в исходное сообщение, остаются в журналах. Добавьте метод Public () string для таких ошибок, и отправляйте версию Public () вашим пользователям, а версию Error () — в журналы.

  • Недостаточно подробные для вас HTTP-статусы? Возможно, вам нужно различать между не найденным виджетом и не найденным пользователем? Вы можете создать свои собственные внутренние статусы/коды ошибок для внутреннего использования, которые приводят к общему HTTP-статусу.

  • Ищите способы следовать DRY. Например, из примера обработчика рассмотрите возможность перемещения вызова r.ParseForm и strconv.Atoi в общую функцию — или используйте библиотеку валидации, например, github.com/go-playground/validator/v10, вместо вызовов strconv, которая возвращает ошибку со статусом 400. Тогда ваш обработчик может просто передать эту ошибку.

У вас есть что сказать по этому поводу? Я хотел бы вас послушать

Есть ли способ лучше?

Я не знаю.

Я всегда в поиске лучшего.

Если вы знаете лучший шаблон или даже небольшие способы улучшить этот шаблон, пожалуйста, дайте мне знать! Я буду рад учиться у вас!

Habrahabr.ru прочитано 7530 раз