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