Шаг за шагом создайте невероятно быстрый API

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

Одним из менее привлекательных аспектов C# является то, что он чрезвычайно многословен. Даже при написании относительно небольшого проекта количество проектов, файлов и строк кода быстро увеличивается.

Чтобы все было под контролем, особенно в крупных проектах, за прошедшие годы появилось множество шаблонов и лучших практик. Для большинства современных веб-проектов на C# внедрение зависимостей является стандартом, доступ к базам данных осуществляется через ORM, а маршрутизация обрабатывается сложной структурой.

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

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

Есть один язык, который, как мне кажется, действительно воплощает единообразие и простоту, и этот язык — Go.

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

Готовиться

Перво-наперво. Вам нужно будет установить Инструменты разработки Go, и вам понадобится редактор кода. На момент написания я использую Go 1.19 и использую Visual Studio Code с расширением Go в качестве редактора.

Вы можете делать большинство вещей с Go прямо из командной строки, поэтому IDE совершенно необязательна. Вы даже можете использовать Блокнот, если хотите.

Когда вы будете готовы, убедитесь, что вы все правильно настроили, создав новую папку и запустив:

$ go mod init aevitas.dev/go-api
go: creating new go.mod: module aevitas.dev/go-api

Вы должны увидеть файл go.mod в новой папке, содержащий следующее:

module aevitas.dev/go-api

go 1.19

Если вы хотите, вы можете изменить имя пакета, например, aevitas.dev/go-api, но имейте в виду, что вам нужно будет применить это изменение ко всем операторам import позже!

Основной пакет

Точка входа приложения Go — package main — этот пакет должен содержать функцию с именем main(), которая будет вызываться при каждом запуске вашего приложения.

Поскольку мы не хотим сами писать весь код для нашего API, а вместо этого используем пакеты для таких вещей, как маршрутизация запросов и получение переменных среды, нам придется познакомиться с менеджером пакетов Go.

В отличие от менеджеров пакетов, таких как NuGet или NPM, менеджер пакетов Go может получить пакет с любого URL-адреса, который вы указываете на go get, если целевой URL-адрес содержит действительный пакет.

Для начала нам нужно получить строку подключения к базе данных из наших переменных среды. Вы можете либо установить переменные среды напрямую, либо использовать файл .env для их хранения. Мы будем использовать последний.

В папке вашего проекта запустите go get github.com/joho/godotenv — это установит пакет godotenv в ваш проект.

После этого создайте файл с именем main.go в том же каталоге, что и go.mod, и добавьте следующий код:

package main

import (
 "log"
 "os"

 "github.com/joho/godotenv"
)

func main() {
 err := godotenv.Load()

 if err != nil {
  log.Fatal(err)
 }

 dsn := os.Getenv("DB_DSN")
}

Вероятно, вы получите предупреждение о том, что dsn не используется. Правильно. Мы вернемся к этому файлу позже и исправим это. Если вас это действительно беспокоит, просто добавьте // в начало, чтобы закомментировать эту строку.

Модель книги

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

Это просто причудливый способ сказать, что нам нужно создать модель для нашей книги. В Go любой код, который может в конечном итоге использоваться извне, должен помещаться в папку pkg (и, наоборот, любой код, который никогда не должен использоваться извне, в папке internal).

Поскольку мы создаем API, мы хотим, чтобы внешние пользователи использовали код нашей модели. Поэтому в корне вашего проекта создайте папку pkg, содержащую один файл: pkg/book.go

В файле book.go добавьте следующий код:

package pkg

type Book struct {
 Id     uint64  `json:"id"`
 ISBN   string  `json:"isbn"`
 Title  string  `json:"title"`
 Author string  `json:"author"`
 Price  float64 `json:"price"`
}

Обратите внимание, как имя пакета изменилось с main на pkg — это указывает компилятору, что код находится в другом пакете. Каждый пакет рассматривается как отдельная единица, поэтому для того, чтобы мы могли использовать этот код, нам нужно определить его как pkg.Book.

Поскольку наша модель в конечном итоге будет сериализована в JSON, мы указываем имя сериализованного свойства после типа поля: json:"id" — сериализатор распознает это и назовет свойство соответствующим образом.

На данный момент достаточно о модели книги. Добавим немного функциональности.

Сервер

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

В этом гайде я сделал ответственным за это тип Server. Я не уверен, что это лучший подход, но из тех, что я видел, он кажется мне наиболее разумным.

Так или иначе, давайте начнем. В папке проекта создайте папку ./api и добавьте server.go, чтобы путь ./api/server.go был действительным.

Наш сервер состоит из двух компонентов: указателя базы данных и маршрутизатора. Чтобы они были организованы, мы определяем их в struct — структуре данных, которая может содержать эти два элемента. Основная предпосылка кода выглядит так:

package api

type Server struct {
 DB  *gorm.DB
 Gin *gin.Engine
}

Конечно, и Gorm, и Gin определены во внешних пакетах. Нам нужно их захватить:

$ go get gorm.io/gorm
$ go get gorm.io/driver/postgres
$ go get github.com/gin-gonic/gin

Обратите внимание, что в этом руководстве я буду использовать локальный сервер PostgreSQL. Если у вас его нет, вполне можно использовать вместо него SQLite — просто
go get gorm.io/driver/sqlite.

Далее мы напишем несколько функций, которые помогут нам настроить экземпляр нашего сервера — инициализировать соединение с базой данных, настроить маршрутизатор и т. д. Мы также добавим функцию Ready(), чтобы указать, готов ли сервер:

func (s *Server) InitDb(dsn string) *Server {
 db, err := gorm.Open(postgres.Open(dsn))

 if err != nil {
  log.Fatal(err)
 }

 s.DB = db

 return s
}

func (s *Server) InitGin() *Server {
 g := gin.Default()

 s.Gin = g

 return s
}

func (s *Server) Ready() bool {
 return s.DB != nil && s.Gin != nil
}

После вставки этого кода вы, вероятно, увидите много красных волнистых линий, потому что компилятор не может разрешить некоторые ссылки. Запустите go mod tidy в своей командной строке, чтобы облегчить это — это обеспечит правильное разрешение всех пакетов.

Наконец, если вы используете SQLite, замените вызов postgres.Open вызовом sqlite.Open и просто используйте имя файла вместо передачи dsn — результирующая строка будет выглядеть примерно так:

db, err := gorm.Open(postgres.Open("books.db"))

Нет смысла иметь сервер, который мы не можем запустить, поэтому в конце файла добавьте:

func (s *Server) Start(ep string) error {
 if !s.Ready() {
  return errors.New("server isn't ready - make sure to init db and gin")
 }

 if err := http.ListenAndServe(ep, s.Gin.Handler()); err != nil {
  log.Fatal(err)
 }

 return nil
}

Все эти функции мы определяем как методы типа Server. Это означает, что мы не можем вызывать их напрямую, а вместо этого должны создать экземпляр сервера и впоследствии вызывать методы. Мы разберемся с этим в файле main.go.

Главный пересмотр

Возвращаясь к main.go , теперь у нас есть тип сервера, который мы можем создать и запустить для обслуживания нашего API. Вокруг строки определения dsn добавьте следующее:

 srv := &api.Server{}

 dsn := os.Getenv("DB_DSN")

 srv.InitDb(dsn)
 srv.InitGin()

 srv.RegisterRoutes()

 srv.Start(":8050")

Первая строка здесь создает новый экземпляр типа Server без указания каких-либо его полей. Последующие вызовы InitDb и InitGin — это те, которые мы только что реализовали, и они настроят нашу базу данных и маршрутизатор.

Наш окончательный файл main.go должен выглядеть так:

package main

import (
 "log"
 "os"

 "aevitas.dev/go-books/api"
 "github.com/joho/godotenv"
)

func main() {
 err := godotenv.Load()

 if err != nil {
  log.Fatal(err)
 }

 srv := &api.Server{}

 dsn := os.Getenv("DB_DSN")

 srv.InitDb(dsn)
 srv.InitGin()

 srv.RegisterRoutes()

 srv.Start(":8050") // Or grab this from the env, too!
}

Наблюдательные из вас заметят две вещи:

  1. На самом деле у нас пока нет API для обслуживания
  2. Мы не реализовали RegisterRoutes в server.go

И то и другое правильно. Давайте добавим несколько обработчиков, чтобы мы могли начать обслуживать некоторый контент.

Обработчики

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

Создайте файл с именем add_book.go в папке api и добавьте следующий код:

package api

import (
 "log"
 "net/http"

 "aevitas.dev/go-books/pkg"
 "github.com/gin-gonic/gin"
 "github.com/oklog/ulid"
)

func (s *Server) HandleAddBook(ctx *gin.Context) {
 var book pkg.Book

 err := ctx.BindJSON(&book)

 if err != nil {
  ctx.AbortWithError(http.StatusBadRequest, err)
  return
 }

 book.Id = ulid.Now()

 r := s.DB.Create(&book)

 if r.Error != nil {
  log.Fatal(r.Error)
 }

 s.DB.Save(&book)

 ctx.JSON(http.StatusOK, &book)
}

Убедитесь, что скачали пакет ulid, а затем запустите go mod tidy.

Параметр ctx содержит контекст Gin, точнее контекст HTTP. Этот контекст содержит запрос, который мы получили от пользователя, а также возможный ответ, который мы ему обслужим.

Первым делом нужно определить переменную book модели, которую мы определили ранее — pkg.Book Затем мы попытаемся десериализовать содержимое запроса в эту переменную. Если все пойдет хорошо, у нас будет действующая книга с названием, ISBN, автором и так далее.

Если нет, мы укоротим запрос, вызвав ctx.AbortWithError и указав неверный запрос вместе с ошибкой, которую мы получили от сериализатора.

Обратите внимание, что хотя это и сокращает конвейер ответа, код все равно будет продолжать выполняться — отсюда и return

Поскольку этот обработчик объявлен как метод Server, у нас будет доступ к указателю s.DB для доступа к базе данных. Мы создадим и сохраним книгу, и, если все пойдет хорошо, вернем вызывающей стороне книгу как объект JSON.

Теперь, когда мы можем создавать книги, мы также должны иметь возможность извлекать их. Добавьте обработчик get_book.go, содержащий следующий (очень похожий) код:

package api

import (
 "net/http"

 "aevitas.dev/go-books/pkg"
 "github.com/gin-gonic/gin"
)

func (s *Server) HandleGetByISBN(ctx *gin.Context) {
 var book pkg.Book

 isbn := ctx.Param("isbn")

 ret := s.DB.First(&book, "isbn = ?", isbn)

 if ret.RowsAffected == 0 {
  ctx.AbortWithStatus(http.StatusNotFound)
  return
 }

 if ret.Error != nil {
  ctx.AbortWithError(http.StatusBadRequest, ret.Error)
  return
 }

 ctx.JSON(http.StatusOK, book)
}

Как видите, основное различие между ними заключается в том, что последний получает ISBN из параметров запроса (поскольку запрос будет GET, а не POST, и, следовательно, не имеет никого) и выполняет простой запрос к базе данных.

Теперь не хватает только (кроме тестов!) функции RegisterRoutes.

Маршруты

Go поставляется с очень надежным HTTP-сервером, включенным в пакет http. Мы будем использовать его для обслуживания нашего API, а Gin — для маршрутизации. Джин намного, намного мощнее, чем то, что я показал в этой статье.

В пакете api создайте файл с именем routes.go и добавьте следующий код:

package api

func (s *Server) RegisterRoutes() {
 s.Gin.GET("/books/:isbn", s.HandleGetByISBN)
 s.Gin.POST("/books", s.HandleAddBook)
}

Подобно отдельным обработчикам, мы объявили эти функции как методы Server, хотя они и находятся в разных файлах. Это возможно только для файлов в одном пакете, и именно так я предпочитаю объединять части функциональности вместе.

Допустим, вы расширили бы этот API подробной информацией об авторе, вы могли бы переместить обработчики, которые мы написали выше, в пакет books, с его собственной функцией RegisterBookRoutes или аналогичной, и сделать то же самое для authors — таким образом вы могли бы упаковать аккуратно разложите ваш API на вертикальные срезы функциональности.

Бег

Вот и все! Теперь просто добавьте файл .env и определите DB_DSN чем-то вроде:

host=localhost user=foo password=bar dbname=books

И вы будете настроены. Запустите приложение, запустив go run ., и вы сможете использовать API на вашем локальном хосте на порту 8050.

Заключение

Мы создали очень простой API на Go, который обращается к базе данных для хранения и извлечения книг, и применили некоторую базовую структуру к нашему проекту, чтобы расширить его в будущем.

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

Окончательный код, сопровождающий эту статью, можно найти здесь.

Спасибо за чтение, и я надеюсь, что это руководство было полезным!