[golang] http server (p.2 - db)

[golang] http server (p.2 - db)

Всем привет! В этой статье будет разобрано, как поднять инстанс 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 // остановка контейнеров

GitHub Repo

https://github.com/rushawx/golang_http_demo