golang

Приложение на Go шаг за шагом. Часть первая: скелет, НТТР-сервер и конфигурация

  • четверг, 31 октября 2024 г. в 00:00:06
https://habr.com/ru/companies/yandex_praktikum/articles/854482/

Современные курсы стараются максимально охватить спектр технологий, которые используют компании. Ориентироваться в этом океане модных фич всё труднее, особенно это касается новичков, которые только начали знакомство с программированием. В итоге может случиться так, что выпускник курса вроде бы всё знает, а применять не может. 

Привет! Я Владислав Попов, автор курса «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-сервер

Теперь, когда структура проекта готова, сосредоточимся на создании и запуске 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 и маршрутизация REST

Теперь мы постепенно будем создавать 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

Код для извлечения параметра 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 вместо обычного текста.