[golang] http server (p.2 - db)
![[golang] http server (p.2 - db)](/content/images/size/w1200/2025/01/679551eecfee11efab3c2ec7f0e890f6_1.jpeg)
Всем привет! В этой статье будет разобрано, как поднять инстанс Postgres в docker контейнере, а также как подружить наше приложение с реляционной базой данных, избавившись от хранения данных в оперативной памяти.
Docker Compose - Postgres
Локально поднять инстанс Postgres можно с помощью Docker. Для этого необходимо скачать Docker Desktop, вместе с которым установятся необходимые утилиты. Например, docker compose, предназначенный для запуска нескольких образов одновременно. Так как при создании приложения нередко требуется поднимать несколько сервисов или вспомогательных ресурсов, то будет рассмотрен пример именно с использованием docker compose. Сперва необходимо создать файл docker-compose.yml
с содержанием, приведенным ниже.
services:
pg:
image: postgres:latest
container_name: pg
env_file:
- ./.env
environment:
- POSTGRES_PASSWORD=${PG_PASSWORD}
- POSTGRES_USER=${PG_USER}
- POSTGRES_DB=${PG_DATABASE}
ports:
- "5432:5432"
volumes:
- ./db_init/pg:/docker-entrypoint-initdb.d
Давайте пройдемся по приведенным пунктам. Пункт services
включает в себя сервисы или ресурсы, которые планируются к локальному запуску. Пункт pg
может носить произвольное имя, это название поднимаемого сервиса. Далее пункт image
указывает на то, какой из образов, хранящихся в репозитории Docker Hub, будет скачан и использован для локального развертывания - в данном случае мы говорим, что нам требуется самый актуальный (latest) образ Postgres. Пункт container_name
также может носить произвольное имя, он отвечает за название, которое будет присвоено контейнеру внутри Docker. Пункт env_file
предназначен для указания пути к файлу с переменными окружения, необходимыми для конфигурации поднимаемого сервиса. Пункт environment
отвечает за непосредственное обозначение переменных окружения, значения для которых можно указать хардом, либо поднянуть из уже упомянутого файла с переменными окружения. Пункт ports
отвечает за порты, по которым будет доступно наше приложение. Пункт volumes
предназначен для обозначения томов с данными, либо в нашем случае для передачи SQL скрипта инициализации. Иногда данный скрипт может включать в себя создание объектов базы данных. В нашем случае мы подключим расширение `uuid-ossp' для работы с типом данных UUID. Пример файла с переменными окружения приведен ниже.
PG_USER="postgres"
PG_PASSWORD="postgres"
PG_HOST="localhost"
PG_PORT="5432"
PG_DATABASE="postgres"
Пример файла со скриптом инициализации базы данных расположен ниже.
create extension if not exists "uuid-ossp";
Далее, подготовив необходимые файлы, можно попробовать поднять контейнер с Postgres, для чего следует выполнить команду, приведенную ниже.
docker compose up -d
Словосочетание docker compose
обозначает, что мы вызываем соответствующее консольное приложение с командой up
и флагом -d
, который предназначен для запуска сервисов в режиме detached, то есть контейнеры будут запущены в фоновом режиме без постоянного вывода логов в терминал.
После того, как мы подняли базу данных в контейнере, можно приступать к настройке интеграции нашего приложения с базой данных.
configs/config.go
Прежде чем реализовать необходимый функционал для работы с базой данных, следует прописать логику работы приложения с собственной конфигурацией и переменными окружения. Для этого создадим папку configs
с файлом config.go
.
mkdir configs
touch configs/config.go
Предварительно необходимо установить пакет github.com/joho/godotenv
.
go get github.com/joho/godotenv
Далее будем работать с файлом configs/config.go
. Создадим необходимые структуры Config
и DbConfig
, а также объявим функцию-конструктор.
// configs/config.go
package configs
import (
"fmt"
"github.com/joho/godotenv"
"log"
"os"
)
type Config struct {
Db DbConfig
}
type DbConfig struct {
Dsn string
}
func DefaultConfig() *Config {
err := godotenv.Load()
if err != nil {
log.Println("Error loading .env file, using defaults.")
}
return &Config{
Db: DbConfig{
Dsn: fmt.Sprintf(
"host=%v user=%v password=%v dbname=%v port=%v",
os.Getenv("PG_HOST"),
os.Getenv("PG_USER"),
os.Getenv("PG_PASSWORD"),
os.Getenv("PG_DATABASE"),
os.Getenv("PG_PORT"),
),
},
}
}
cmd/main.go
Теперь нам необходимо инициализировать конфиги для использования в точке входа в приложение, в частности для передачи их содержимого в функцию конструктор соединения с базой данных.
// cmd/main.go
// cmd/main.go
package main
import (
"fmt"
"hw/configs"
"hw/internal/todo"
"hw/pkg/db"
"net/http"
)
func main() {
conf := configs.DefaultConfig()
pgDb := db.NewDb(conf)
fmt.Println(conf.Db.Dsn)
router := http.NewServeMux()
taskRepository := todo.NewTaskRepository(pgDb)
todo.NewTaskHandler(router, taskRepository)
server := http.Server{
Addr: ":8080",
Handler: router,
}
fmt.Println("Server is listening on port 8080")
server.ListenAndServe()
}
pkg/db.go
При создании интеграции с реляционной базой данных начать следует с установки ORM - gorm - и драйвера postgres.
go get gorm.io/gorm
go get gorm.io/driver/postgres
После чего перепишем функционал нашего пакета db
следующим образом.
// pkg/db/db.go
package db
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
"hw/configs"
"log"
)
type Db struct {
*gorm.DB
}
// Db - база данных с хранением в Postgres
func NewDb(config *configs.Config) *Db {
db, err := gorm.Open(postgres.Open(config.Db.Dsn), &gorm.Config{})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
return &Db{db}
}
// NewDb создает новый экземпляр базы данных Postgres
internal/todo/repository.go
Затем обновим логику работы нашего репозитория, заменив использование методов для хранения данных в map на применение методов для хранения данных в базе данных Postgres с помощью ORM.
// internal/todo/repository.go
package todo
import (
"gorm.io/gorm/clause"
"hw/pkg/db"
)
type TaskRepository struct {
Database *db.Db
}
// TaskRepository - репозиторий для работы с задачами
func NewTaskRepository(database *db.Db) *TaskRepository {
return &TaskRepository{
Database: database,
}
}
// NewTaskRepository создает новый экземпляр репозитория
func (repo *TaskRepository) Create(task *Task) (*Task, error) {
result := repo.Database.Create(task)
if result.Error != nil {
return nil, result.Error
}
return task, nil
}
// Create добавляет задачу в базу данных
func (repo *TaskRepository) GetById(id string) (*Task, error) {
data := Task{}
result := repo.Database.First(&data, "task_id = ?", id)
if result.Error != nil {
return nil, result.Error
}
return &data, nil
}
// GetByID возвращает задачу из базы данных по идентификатору
func (repo *TaskRepository) GetAll() ([]Task, error) {
var tasks []Task
result := repo.Database.Find(&tasks)
if result.Error != nil {
return nil, result.Error
}
return tasks, nil
}
// GetAll возвращает все задачи из базы данных
func (repo *TaskRepository) Update(task *Task) (*Task, error) {
result := repo.Database.Clauses(clause.Returning{}).Where("task_id = ?", task.TaskID).Updates(task)
if result.Error != nil {
return nil, result.Error
}
return task, nil
}
// Update обновляет задачу в базе данных
func (repo *TaskRepository) Delete(id string) error {
task, err := repo.GetById(id)
if err != nil {
return err
}
result := repo.Database.Delete(task)
return result.Error
}
// Delete удаляет задачу из базы данных
internal/todo/model.go
Помимо этого, следует обновить нашу модель следующим образом с учетом использования ORM.
// internal/todo/model.go
package todo
import (
"github.com/google/uuid"
"gorm.io/gorm"
)
type Task struct {
gorm.Model
TaskID uuid.UUID `json:"task_id" gorm:"type:uuid;default:uuid_generate_v4();uniqueIndex"`
Title string `json:"title"`
Description string `json:"description"`
Done bool `json:"done"`
}
// Task - структура
// с помощью которой будет передаваться и храниться информация о задаче
// из запроса со стороны клиента
internal/todo/handler.go
Также нам потребуется немного скорректировать содержимое хэндлеров.
// internal/todo/handler.go
package todo
import (
"encoding/json"
"gorm.io/gorm"
"net/http"
)
type TaskHandler struct {
TaskRepository *TaskRepository
}
func NewTaskHandler(router *http.ServeMux, taskRepository *TaskRepository) {
handler := &TaskHandler{
TaskRepository: taskRepository,
}
router.HandleFunc("GET /tasks", handler.GetAll())
router.HandleFunc("GET /tasks/{id}", handler.GetById())
router.HandleFunc("POST /tasks", handler.Create())
router.HandleFunc("PUT /tasks/{id}", handler.Update())
router.HandleFunc("DELETE /tasks/{id}", handler.Delete())
}
func (h *TaskHandler) GetAll() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := h.TaskRepository.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
}
}
func (h *TaskHandler) GetById() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
data, err := h.TaskRepository.GetById(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
}
}
func (h *TaskHandler) Create() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
task.Done = false
data, err := h.TaskRepository.Create(&task)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(data)
}
}
func (h *TaskHandler) Update() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
taskDb, err := h.TaskRepository.GetById(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
var taskTitle string
if task.Title != "" {
taskTitle = task.Title
} else {
taskTitle = taskDb.Title
}
var taskDesc string
if task.Description != "" {
taskDesc = task.Description
} else {
taskDesc = taskDb.Description
}
var taskDone bool
if task.Done {
taskDone = true
} else {
taskDone = taskDb.Done
}
output, err := h.TaskRepository.Update(&Task{
Model: gorm.Model{ID: taskDb.Model.ID},
TaskID: taskDb.TaskID,
Title: taskTitle,
Description: taskDesc,
Done: taskDone,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(output)
}
}
func (h *TaskHandler) Delete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
err := h.TaskRepository.Delete(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
migrations/init.go
Мы сделали почти все необходимое для настройки интергации нашего приложения с базой данных Postgres, однако при текущей реализации база данных изначально не будет содержать необходимых нам объектов. Чтобы это исправить, следует задать и запустить миграции, конфигурируемые с помощью файла migrations/init.go
.
mkdir migrations
touch migrations/init.go
Далее заполним файл migrations/init.go
следующим содержимым.
package main
import (
"fmt"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"hw/internal/todo"
"log"
"os"
)
func main() {
err := godotenv.Load(".env")
if err != nil {
log.Fatal("Error loading .env file")
}
db, err := gorm.Open(
postgres.Open(
fmt.Sprintf(
"host=%v user=%v password=%v dbname=%v port=%v",
os.Getenv("PG_HOST"),
os.Getenv("PG_USER"),
os.Getenv("PG_PASSWORD"),
os.Getenv("PG_DATABASE"),
os.Getenv("PG_PORT"),
),
),
&gorm.Config{},
)
if err != nil {
log.Fatal(err)
}
err = db.AutoMigrate(&todo.Task{})
if err != nil {
log.Fatal(err)
}
}
После чего можно будет запустить миграции.
go run migrations/init.go
Conclusion
После этих доработок наше приложение будет хранить данные не в оперативной памяти, а в реляционной базе данных Postgres.
go run cmd/main.go // запуск приложения
После проверки функционала можно остановить приложение с помощью Ctlr+C и остановить контейнеры в Docker.
docker compose down --volumes // остановка контейнеров