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

от автора

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

Привет! Я Владислав Попов, автор курса «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 вместо обычного текста.


ссылка на оригинал статьи https://habr.com/ru/articles/854482/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *