Шаг за шагом создайте невероятно быстрый 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! }
Наблюдательные из вас заметят две вещи:
- На самом деле у нас пока нет API для обслуживания
- Мы не реализовали
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, который обращается к базе данных для хранения и извлечения книг, и применили некоторую базовую структуру к нашему проекту, чтобы расширить его в будущем.
Насколько я могу судить, не существует единой структуры, которая была бы «лучшей практикой» или стандартом, как в других языках. Это во многом зависит от ваших индивидуальных потребностей и предпочтений и, в конечном счете, от того, что вы можете придумать, что сработает. Возможно, именно поэтому я рассматриваю этот язык как глоток свежего воздуха.
Окончательный код, сопровождающий эту статью, можно найти здесь.
Спасибо за чтение, и я надеюсь, что это руководство было полезным!