Приложение на Go шаг за шагом. Часть первая: скелет, НТТР-сервер и конфигурация
- четверг, 31 октября 2024 г. в 00:00:06
Современные курсы стараются максимально охватить спектр технологий, которые используют компании. Ориентироваться в этом океане модных фич всё труднее, особенно это касается новичков, которые только начали знакомство с программированием. В итоге может случиться так, что выпускник курса вроде бы всё знает, а применять не может.
Привет! Я Владислав Попов, автор курса «Go-разработчик с нуля» в Яндекс Практикуме. В серии статей я хочу помочь начинающим разработчикам упорядочить знания и написать приложение на Go с нуля: мы вместе пройдём каждый шаг и создадим API для получения информации о книгах и управления ими.
Серия статей ни в коем случае не претендует на звание универсального руководства по созданию сервисов. Тем не менее я надеюсь, она поможет собрать весь (или почти весь) пазл из полученных знаний.
Предполагается, что вы знакомы с языком программирования Go и он у вас установлен. Также вам потребуются: VS Code с установленным плагином Go, инструмент для работы с HTTP-запросами и ответами в терминале curl, система контроля версий Git и веб-браузер.
Начнём с создания директории проекта. Назовем её itbookworm
. Я создам директорию проекта в $HOME/go/src/github.com/lekan-pvp/itbookworm
, но вы можете сделать это там, где вам нравится:
$ mkdir -p $HOME/go/src/github.com/lekan-pvp/itbookworm
Перейдём в каталог проекта и создадим модуль с помощью go mod init
:
$ cd $HOME/go/src/github.com/lekan-pvp/itbookworm
$ go mod init github.com/lekan-pvp/itbookworm
go: creating new go.mod: module github.com/lekan-pvp/itbookworm
В итоге мы видим, что был создан файл go.mod
со следующим содержимым (название модуля и версия Go могут отличаться):
module github.com/lekan-pvp/itbookworm
go 1.23.2
Итак, каталог проекта и файл go.mod
созданы. Продолжим создавать структуру проекта, запуская следующие команды:
$ mkdir -p bin cmd/api internal migrations remote
$ touch Makefile
$ touch cmd/api/main.go
На данный момент структура проекта выглядит так:
.
|---bin
|---cmd
| +---api
| +---main.go
|---internal
|---migrations
|---remote
|---go.mod
|---Makefile
Разберёмся, что будет содержать каждый каталог:
bin
— скомпилированные двоичные файлы, готовые к развёртыванию на рабочем сервере;
cmd/api
— основная функция приложения;
internal
— различные вспомогательные пакеты;
migrations
— файлы миграции для базы данных;
remote
— файлы конфигурации и сценарии настроек для производственного сервера;
go.mod
— информация о зависимостях проекта, версиях и путях к модулям;
Makefile
— инструкции по автоматизации частых административных задач — проверка кода, создание двоичных файлов и выполнения миграций.
Прежде чем продолжить, проверим, что все наши настройки корректны. Откроем cmd/api/main.go
и добавим код:
// cmd/api/main.go
package main
import "fmt"
func main() {
fmt.Println("hello world!")
}
Сохраним файл и запустим его:
$ go run ./cmd/api
hello world!
Теперь, когда структура проекта готова, сосредоточимся на создании и запуске HTTP-сервера. Сначала будет только один эндпоинт /v1/healthcheck
, который будет возвращать основную информацию об API: текущую версию и производственную среду (разработка, этап, производство; development, stage, production).
Снова откроем cmd/api/main.go
в редакторе и заменим приложение hello world
следующим кодом:
// cmd/api/main.go
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
)
// Объявим строковую константу, которая содержит номер версии приложения.
// Позже мы будем генерировать версию автоматически во время сборки, а пока
// просто сохраним жёстко заданную глобальную константу.
const version = "1.0.0"
// Определим структуру конфигурации, которая будет содержать все параметры
// конфигурации для нашего приложения. На данный момент параметрами будут порт,
// который должен прослушивать сервер, и название производственной среды (разработка, производство и т.д.). Эти параметры будут считываться из флагов командной строки.
type config struct {
port int
env string
}
// Определим структуру приложения, которая будет содержать зависимости для
// обработчиков HTTP, вспомогательных функций и middleware. На данный момент
// она содержит копию структуры конфигурации и логгер. По мере развития проекта
// она будет включать в себя гораздо больше.
type application struct {
config config
logger *log.Logger
}
func main() {
// Объявляем экземпляр структуры config.
var cfg config
// Записываем значения флагов командной строки port и env в структуру
// конфигурации. По умолчанию мы используем номер порта 8000 и среду development.
flag.IntVar(&cfg.port, "port", 8000, "API server port")
flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")
flag.Parse()
// Инициализируем новый логгер, который записывает сообщения в стандартный вывод
// с указанием текущей даты и времени.
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime)
// Объявляем экземпляр структуры приложения, которая содержит структуру
// конфигурации и логгер.
app := &application{
config: cfg,
logger: logger,
}
// Объявляем новый мультиплексор и добавляем маршрут `/v1/healthcheck`,
// который будет перенаправлять запросы в метод `healhcheckHandler`
// (мы создадим его чуть позже).
mux := http.NewServeMux()
mux.HandleFunc("/v1/healthcheck", app.healthcheckHandler)
// Объявляем HTTP-сервер с настройками тайм-аута, который прослушивает порт,
// указанный в структуре конфигурации, и использует созданный выше мультиплексор.
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.port),
Handler: mux,
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
// Запускаем HTTP-сервер
logger.Printf("starting %s server on %s", cfg.env, srv.Addr)
err := srv.ListenAndServe()
logger.Fatal(err)
}
Следующее, что нам нужно сделать, — создать метод healthcheckHandler
для обработки HTTP-запросов. Сейчас обработчик будет возвращать простой текст, который будет содержать:
Строку status: available
;
Версию API из жёстко запрограммированной константы version
;
Название среды разработки, которая будет взята из флага командной строки env
.
Создадим новый файл cmd/api/healthcheck.go
:
$ touch cmd/api/healthcheck.go
И добавим в него код:
// cmd/api/healthcheck.go
package main
import (
"fmt"
"net/http"
)
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "status: available")
fmt.Fprintf(w, "environment: %s\n", app.config.env)
fmt.Fprintf(w, "version: %s\n", version)
Самое важное в приведённом коде: понимание, что healhcheckHandler
— это метод для структуры application
. Это самый эффективный и общепринятый способ сделать зависимости доступными для обработчиков, не используя глобальные переменные или замыкания.
Чтобы добавить нужную обработчику зависимость, достаточно включить её в структуру приложения. В нашем коде мы применили этот паттерн, когда использовали app.config.env
.
Снова запустите сервер командой go run cmd/app/
и в браузере перейдите по адресу localhost:4000/healthcheck
. Сервер вернёт:
status: available
environment: development
version: 1.0.0
Или используйте curl
в терминале, где -i
говорит curl
выводить заголовок и тело ответа от сервера:
$ curl -i localhost:4000/v1/healthcheck
HTTP/1.1 200 OK
Date: Mon, 14 Oct 2024 18:52:38 GMT
Content-Length: 58
Content-Type: text/plain; charset=utf-8
status: available
environment: development
version: 1.0.0
Попробуйте запустить сервер с флагами, которые вы запрограммировали, и другими значениями для них.
Теперь мы постепенно будем создавать API, чтобы конечные точки выглядели так:
Метод | Эндпоинт | Хендлер | Действие |
GET | /v1/healthcheck | healthcheckHandler | Показывает информацию о приложении |
GET | /v1/books| | listBooksHandler | Показывает информацию обо всех книгах |
POST | /v1/books | createBookHandler | Создаёт новую книгу |
GET | v1/books/{id} | showBookHandler | Показывает детали определённой книги |
PUT | /v1/books/{id} | editBookHandler | Обновляет информацию определённой книги |
DELETE | /v1/books/{id} | deleteBookHandler | Удаляет определённую книгу |
Одним из первых препятствий, с которыми вы столкнётесь при создании API, будет ограниченный функционал маршрутизатора из стандартной библиотеки http.ServeMux
.
Этот роутер не позволяет перенаправлять запросы на разные обработчики в зависимости от методов запроса (GET, POST и т.д.) и не поддерживает чистые URL-адреса со встроенными в путь параметрами.
Например, чтобы получить сведения о конкретной книге, клиент отправляет запрос GET /v1/books/1
вместо того, чтобы добавлять идентификатор книги в качестве параметра строки запроса GET /v1/books?id=1
.
Мы будем использовать популярный маршрутизатор chi. Чтобы его получить, выполним команду:
$ go get github.com/go-chi/chi/v5
go: downloading github.com/go-chi/chi/v5 v5.1.0
go: added github.com/go-chi/chi/v5 v5.1.0
Начнём работу с chi
, добавив в наше приложение два эндпоинта: первый для создания новой книги, второй — для вывода сведений о конкретной книге. К концу этой статьи у нас будет три конечные точки и три обработчика.
Метод | Эндпоинт | Хендлер | Действие |
GET | /v1/healthcheck | healthcheckHandler | Показывает информацию о приложении |
POST | /v1/books | createHandler | Создает новую книгу |
GET | /v1/books/{id} | showBookHandler | Показывает детали определённой книги |
Чтобы функция main()
не стала слишком громоздкой, выделим все правила маршрутизации в отдельный файл cmd/api/routes.go
.
$ touch cmd/api/routes.go
И добавим в него код:
// cmd/api/routes.go
package main
import (
"github.com/go-chi/chi/v5"
)
func (app *application) routes() *chi.Mux {
// Инициализируем новый маршрутизатор chi.
router := chi.NewRouter()
// Регистрируем шаблоны URL и методы обработчиков.
router.Get("/v1/healthcheck", app.healthcheckHandler)
router.Post("/v1/books", app.createBookHandler)
router.Get("/v1/books/{id}", app.showBookHandler)
// Возвращаем экземпляр chi.Mux
return router
}
Обновим функцию main()
, удалив из неё объявление `http.ServeMux`. Используем экземпляр `chi.Mux`, который возвращает метод app.routes()
:
// cmd/api/main.go
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
)
const version = "1.0.0"
type config struct {
port int
env string
}
type application struct {
config config
logger *log.Logger
}
func main() {
var cfg config
flag.IntVar(&cfg.port, "port", 4000, "API server port")
flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")
flag.Parse()
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime)
app := &application {
config: cfg,
logger: logger,
}
srv := &http.Server {
Addr: fmt.Sprintf(":%d",cfg.port),
Handler: app.routes(),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
logger.Printf("starting %s server on %s", cfg.env, srv.Addr)
err := srv.ListenAndServe()
logger.Fatal(err)
}
Теперь, когда правила маршрутизации настроены, мы можем создать методы createBookHandler
и showBookHandler
для новых эндпоинтов. Метод showBookHandler
представляет некоторый интерес, потому что в нём мы будем извлекать параметр id
из URL-адреса и использовать его в HTTP-ответе.
Создадим файл cmd/api/books.go
:
touch cmd/api/books.go
И добавим в него код:
// cmd/api/books.go
package main
import (
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
// Добавляем обработчик createBookHandler для эндпоинта `POST /v1/books`.
// Пока мы просто возвращаем текст.
func (app *application) createBookHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "create a new book")
}
// Добавляем обработчик showBookHandler для конечной точки `GET /v1/books/:id`.
// На данный момент мы получаем параметр id из URL-адреса и включаем его в ответ.
func (app *application) showBookHandler(w http.ResponseWriter, r *http.Request) {
// Мы можем использовать функцию chi.URLParam(), чтобы получить параметр id из URL.
// Первый параметр в функции — это http.Request.
param := chi.URLParam(r, "id")
// В нашем проекте все книги будут иметь уникальный положительный идентификатор,
// но возвращаемое значение метода URLParam() всегда является строкой,
// поэтому мы пытаемся преобразовать её в целое число. Если не удаётся
// её преобразовать или id меньше 1, то идентификатор является недействительным, и мы
// вызываем http.NotFound() для возврата ошибки 404.
id, err := strconv.ParseInt(param, 10, 64)
if err != nil || id < 1 {
http.NotFound(w, r)
return
}
// Если идентификатор валидный, мы возвращаем его в ответе.
fmt.Fprintf(w, "show the details of book %d\n", id)
}
Давайте проверим, как всё работает. Перезапустите приложение:
$ go run ./cmd/api
2024/10/21 17:13:52 starting development server on :4000
Пока сервер запущен, откройте другой терминал и введите запросы для каждого обработчика с помощью команды curl
:
$ curl localhost:4000/v1/healthcheck
status: available
environment: development
version: 1.0.0
$ curl -X POST localhost:4000/v1/books
create a new book
$ curl localhost:4000/v1/books/12
show the details of book 12
Обратите внимание, что в последнем запросе значение параметра 12 было успешно получено из URL и включено в ответ.
А что, если сделать запрос с неподдерживаемым методом:
$ curl -i -X POST localhost:4000/v1/healthcheck
HTTP/1.1 405 Method Not Allowed
Allow: GET, OPTIONS
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Mon, 21 Oct 2024 14:28:52 GMT
Content-Length: 19
Method Not Allowed
Получили ошибку 405 Method Not Allowed
— метод не поддерживается.
А теперь попробуем передать в запросе отрицательный id
, чтобы получить ошибку 404 Not Found
:
$ curl -i localhost:4000/v1/books/-3
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Mon, 21 Oct 2024 14:37:01 GMT
Content-Length: 19
404 page not found
В качестве самостоятельной работы сделайте запрос, где id будет строкой, которую невозможно преобразовать в целое положительное число. Обратите внимание на ошибку.
Код для извлечения параметра id
из URL, например, из /v1/books/{id}
, понадобится нам неоднократно. Поэтому давайте вынесем эту логику в небольшой вспомогательный метод, который можно будет использовать повторно.
Создадим новый файл cmd/api/helpers.go
:
$ touch cmd/api/helpers.go
И добавим новый метод для структуры application
:
// cmd/api/helpers.go
package main
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
// readIDParam получает параметр id из контекста запроса, преобразует его в
// целое число и возвращает его. Если операция не удалась, возвращает 0 и сообщение
// об ошибке.
func (app *application) readIDParam(r *http.Request) (int64, error) {
param := chi.URLParam(r, "id")
id, err := strconv.ParseInt(param, 10, 64)
if err != nil || id < 1 {
return 0, errors.New("invalid parameter")
}
return id, nil
}
С помощью нового метода readIDParam()
упростим код в обработчике showBookHandler()
:
// cmd/api/books.go
package main
// ...
func (app *application) showBookHandler(w http.ResponseWriter, r *http.Request) {
id, err := app.readIDParam(r)
if err != nil {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "show the details of book %d\n", id)
}
На этом первая часть завершена. На данном этапе мы уже создали сервер и добавили три обработчика для трёх эндпоинтов. Мы также добавили простенькую конфигурацию и логгер, которые впоследствии будем развивать.
В следующей части начнём работать с JSON. Мы обновим наши обработчики, чтобы он возвращали JSON вместо обычного текста.