refactor: разделение кода на пакеты и улучшение архитектуры

- Создана новая структура проекта с разделением на пакеты
- Добавлены интерфейсы для всех сервисов (Git, Quartz, Files, Build)
- Реализован Dependency Injection для сервисов
- Добавлены middleware для логирования, Request ID и Response Time
- Создан пакет конфигурации с валидацией
- Улучшено логирование через интерфейс
- Добавлены обработчики HTTP в отдельных пакетах
- Создана структура для тестирования
- Добавлены конфигурационные файлы и документация
This commit is contained in:
Andrey Epifancev
2025-08-11 19:45:54 +04:00
parent 1b340362be
commit 04cea69d6e
16 changed files with 1293 additions and 0 deletions

101
internal/config/config.go Normal file
View File

@@ -0,0 +1,101 @@
package config
import (
"errors"
"os"
"strconv"
)
// Config содержит все настройки приложения
type Config struct {
Server ServerConfig `yaml:"server"`
Paths PathsConfig `yaml:"paths"`
Git GitConfig `yaml:"git"`
}
// ServerConfig содержит настройки HTTP сервера
type ServerConfig struct {
Port string `yaml:"port"`
Timeout int `yaml:"timeout"` // в секундах
}
// PathsConfig содержит пути к директориям
type PathsConfig struct {
Obsidian string `yaml:"obsidian"`
Quartz string `yaml:"quartz"`
Public string `yaml:"public"`
}
// GitConfig содержит настройки Git
type GitConfig struct {
Branch string `yaml:"branch"`
Remote string `yaml:"remote"`
}
// Load загружает конфигурацию из переменных окружения
func Load() *Config {
config := &Config{
Server: ServerConfig{
Port: getEnv("PORT", "3000"),
Timeout: getEnvAsInt("SERVER_TIMEOUT", 30),
},
Paths: PathsConfig{
Obsidian: getEnv("OBSIDIAN_PATH", "/obsidian"),
Quartz: getEnv("QUARTZ_PATH", "/quartz"),
Public: getEnv("PUBLIC_PATH", "/public"),
},
Git: GitConfig{
Branch: getEnv("GIT_BRANCH", "main"),
Remote: getEnv("GIT_REMOTE", "origin"),
},
}
return config
}
// Validate проверяет корректность конфигурации
func (c *Config) Validate() error {
if c.Server.Port == "" {
return errors.New("server port is required")
}
if c.Paths.Obsidian == "" {
return errors.New("obsidian path is required")
}
if c.Paths.Quartz == "" {
return errors.New("quartz path is required")
}
if c.Paths.Public == "" {
return errors.New("public path is required")
}
if c.Git.Branch == "" {
return errors.New("git branch is required")
}
if c.Git.Remote == "" {
return errors.New("git remote is required")
}
return nil
}
// getEnv получает значение переменной окружения или возвращает значение по умолчанию
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvAsInt получает значение переменной окружения как int или возвращает значение по умолчанию
func getEnvAsInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}

View File

@@ -0,0 +1,37 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"go-webhook-server/pkg/logger"
)
// HealthHandler обработчик для health check эндпоинта
type HealthHandler struct {
logger logger.Logger
}
// NewHealthHandler создает новый экземпляр health обработчика
func NewHealthHandler(log logger.Logger) *HealthHandler {
return &HealthHandler{
logger: log,
}
}
// HandleHealth обрабатывает health check запрос
func (h *HealthHandler) HandleHealth(c *gin.Context) {
requestID := c.GetString("request_id")
logger := h.logger.WithField("request_id", requestID)
logger.Debug("Health check request received")
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"service": "go-webhook-server",
"version": "1.0.0",
"request_id": requestID,
})
}

View File

@@ -0,0 +1,50 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"go-webhook-server/internal/services"
"go-webhook-server/pkg/logger"
)
// WebhookHandler обработчик для webhook эндпоинта
type WebhookHandler struct {
buildService services.BuildService
logger logger.Logger
}
// NewWebhookHandler создает новый экземпляр webhook обработчика
func NewWebhookHandler(buildService services.BuildService, log logger.Logger) *WebhookHandler {
return &WebhookHandler{
buildService: buildService,
logger: log,
}
}
// HandleWebhook обрабатывает webhook запрос
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
requestID := c.GetString("request_id")
logger := h.logger.WithField("request_id", requestID)
logger.Info("Webhook received, starting site rebuild...")
// Запускаем сборку в горутине для асинхронной обработки
go func() {
result := h.buildService.BuildSite()
if result.Success {
logger.Info("Webhook build completed successfully")
} else {
logger.Errorf("Webhook build failed: %s", result.Error)
}
}()
// Сразу возвращаем ответ
c.JSON(http.StatusAccepted, gin.H{
"status": "accepted",
"message": "Build process started",
"request_id": requestID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -0,0 +1,67 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"go-webhook-server/pkg/logger"
)
// LoggingMiddleware создает middleware для логирования HTTP запросов
func LoggingMiddleware(log logger.Logger) gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
// Логируем детали запроса
log.WithFields(map[string]interface{}{
"status": param.StatusCode,
"latency": param.Latency,
"client_ip": param.ClientIP,
"method": param.Method,
"path": param.Path,
"user_agent": param.Request.UserAgent(),
}).Info("HTTP Request")
// Возвращаем пустую строку, так как логирование уже выполнено
return ""
})
}
// RequestIDMiddleware добавляет уникальный ID к каждому запросу
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
// ResponseTimeMiddleware добавляет время ответа в заголовки
func ResponseTimeMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
c.Header("X-Response-Time", duration.String())
}
}
// generateRequestID генерирует простой ID запроса
func generateRequestID() string {
return time.Now().Format("20060102150405") + "-" + randomString(6)
}
// randomString генерирует случайную строку указанной длины
func randomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
}
return string(b)
}

View File

@@ -0,0 +1,88 @@
package services
import (
"go-webhook-server/internal/config"
"go-webhook-server/pkg/logger"
)
// BuildService интерфейс для сборки сайта
type BuildService interface {
BuildSite() BuildResult
}
// buildServiceImpl реализация сервиса сборки
type buildServiceImpl struct {
config *config.Config
logger logger.Logger
gitService GitService
quartzService QuartzService
fileService FileService
}
// NewBuildService создает новый экземпляр сервиса сборки
func NewBuildService(
cfg *config.Config,
log logger.Logger,
git GitService,
quartz QuartzService,
files FileService,
) BuildService {
return &buildServiceImpl{
config: cfg,
logger: log,
gitService: git,
quartzService: quartz,
fileService: files,
}
}
// BuildSite выполняет полную сборку сайта
func (b *buildServiceImpl) BuildSite() BuildResult {
b.logger.Info("Starting site build process...")
// Проверяем существование репозитория
if !b.gitService.IsRepositoryExists() {
b.logger.Error("Repository not found")
return BuildResult{
Success: false,
Message: "Repository not found",
Error: "Git repository does not exist at specified path",
}
}
// Обновляем репозиторий
if err := b.gitService.UpdateRepository(); err != nil {
b.logger.Errorf("Failed to update repository: %v", err)
return BuildResult{
Success: false,
Message: "Failed to update repository",
Error: err.Error(),
}
}
// Собираем сайт с помощью Quartz
if err := b.quartzService.BuildSite(); err != nil {
b.logger.Errorf("Failed to build Quartz site: %v", err)
return BuildResult{
Success: false,
Message: "Failed to build Quartz site",
Error: err.Error(),
}
}
// Копируем собранные файлы в публичную директорию
if err := b.fileService.CopyBuiltSite(); err != nil {
b.logger.Errorf("Failed to copy built site: %v", err)
return BuildResult{
Success: false,
Message: "Failed to copy built site",
Error: err.Error(),
}
}
b.logger.Info("Site built successfully!")
return BuildResult{
Success: true,
Message: "Site built successfully",
}
}

View File

@@ -0,0 +1,78 @@
package services
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"go-webhook-server/internal/config"
"go-webhook-server/pkg/logger"
)
// FileService интерфейс для файловых операций
type FileService interface {
CopyBuiltSite() error
ClearPublicDirectory() error
EnsurePublicDirectory() error
}
// fileServiceImpl реализация файлового сервиса
type fileServiceImpl struct {
config *config.Config
logger logger.Logger
}
// NewFileService создает новый экземпляр файлового сервиса
func NewFileService(cfg *config.Config, log logger.Logger) FileService {
return &fileServiceImpl{
config: cfg,
logger: log,
}
}
// CopyBuiltSite копирует собранные файлы в публичную директорию
func (f *fileServiceImpl) CopyBuiltSite() error {
f.logger.Info("Copying built site to public directory...")
// Очищаем публичную директорию
if err := f.ClearPublicDirectory(); err != nil {
return fmt.Errorf("failed to clear public directory: %w", err)
}
// Создаем публичную директорию заново
if err := f.EnsurePublicDirectory(); err != nil {
return fmt.Errorf("failed to create public directory: %w", err)
}
// Проверяем существование директории с собранными файлами
quartzPublicPath := filepath.Join(f.config.Paths.Quartz, "public")
if _, err := os.Stat(quartzPublicPath); os.IsNotExist(err) {
return fmt.Errorf("Quartz public directory not found: %s", quartzPublicPath)
}
// Копируем файлы с помощью cp команды
cmd := exec.Command("cp", "-r", quartzPublicPath+"/.", f.config.Paths.Public+"/")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to copy built site: %w", err)
}
f.logger.Info("Site files copied successfully")
return nil
}
// ClearPublicDirectory очищает публичную директорию
func (f *fileServiceImpl) ClearPublicDirectory() error {
if err := os.RemoveAll(f.config.Paths.Public); err != nil {
return fmt.Errorf("failed to clear public directory: %w", err)
}
return nil
}
// EnsurePublicDirectory создает публичную директорию если она не существует
func (f *fileServiceImpl) EnsurePublicDirectory() error {
if err := os.MkdirAll(f.config.Paths.Public, 0755); err != nil {
return fmt.Errorf("failed to create public directory: %w", err)
}
return nil
}

93
internal/services/git.go Normal file
View File

@@ -0,0 +1,93 @@
package services
import (
"fmt"
"path/filepath"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"go-webhook-server/internal/config"
"go-webhook-server/pkg/logger"
)
// GitService интерфейс для Git операций
type GitService interface {
UpdateRepository() error
IsRepositoryExists() bool
}
// gitServiceImpl реализация Git сервиса
type gitServiceImpl struct {
config *config.Config
logger logger.Logger
}
// NewGitService создает новый экземпляр Git сервиса
func NewGitService(cfg *config.Config, log logger.Logger) GitService {
return &gitServiceImpl{
config: cfg,
logger: log,
}
}
// UpdateRepository обновляет репозиторий из удаленного источника
func (g *gitServiceImpl) UpdateRepository() error {
g.logger.Info("Updating repository...")
// Открываем репозиторий
repo, err := git.PlainOpen(g.config.Paths.Obsidian)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
// Получаем worktree
worktree, err := repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
// Проверяем текущую ветку
head, err := repo.Head()
if err != nil {
return fmt.Errorf("failed to get HEAD: %w", err)
}
g.logger.Infof("Current branch: %s", head.Name().Short())
// Выполняем git pull
err = worktree.Pull(&git.PullOptions{
RemoteName: g.config.Git.Remote,
ReferenceName: plumbing.NewBranchReferenceName(g.config.Git.Branch),
})
if err != nil {
if err == git.NoErrAlreadyUpToDate {
g.logger.Info("Repository is already up to date")
return nil
}
return fmt.Errorf("failed to pull from remote: %w", err)
}
g.logger.Info("Repository updated successfully")
return nil
}
// IsRepositoryExists проверяет существование Git репозитория
func (g *gitServiceImpl) IsRepositoryExists() bool {
gitPath := filepath.Join(g.config.Paths.Obsidian, ".git")
// Проверяем существование .git директории
if _, err := filepath.Abs(gitPath); err != nil {
return false
}
// Пытаемся открыть репозиторий
repo, err := git.PlainOpen(g.config.Paths.Obsidian)
if err != nil {
return false
}
// Проверяем что это действительно Git репозиторий
_, err = repo.Head()
return err == nil
}

View File

@@ -0,0 +1,85 @@
package services
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"go-webhook-server/internal/config"
"go-webhook-server/pkg/logger"
)
// QuartzService интерфейс для сборки Quartz сайта
type QuartzService interface {
BuildSite() error
InstallDependencies() error
}
// quartzServiceImpl реализация Quartz сервиса
type quartzServiceImpl struct {
config *config.Config
logger logger.Logger
}
// NewQuartzService создает новый экземпляр Quartz сервиса
func NewQuartzService(cfg *config.Config, log logger.Logger) QuartzService {
return &quartzServiceImpl{
config: cfg,
logger: log,
}
}
// BuildSite собирает сайт с помощью Quartz
func (q *quartzServiceImpl) BuildSite() error {
q.logger.Info("Building site with Quartz...")
// Проверяем существование package.json в директории Quartz
packageJSONPath := filepath.Join(q.config.Paths.Quartz, "package.json")
if _, err := os.Stat(packageJSONPath); os.IsNotExist(err) {
return fmt.Errorf("package.json not found in Quartz directory: %s", q.config.Paths.Quartz)
}
// Устанавливаем зависимости если необходимо
if err := q.InstallDependencies(); err != nil {
return fmt.Errorf("failed to install dependencies: %w", err)
}
// Выполняем сборку Quartz
cmd := exec.Command("npm", "run", "quartz", "build", "--", "-d", q.config.Paths.Obsidian)
cmd.Dir = q.config.Paths.Quartz
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
q.logger.Info("Executing Quartz build command...")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to build Quartz site: %w", err)
}
q.logger.Info("Quartz build completed successfully")
return nil
}
// InstallDependencies устанавливает npm зависимости
func (q *quartzServiceImpl) InstallDependencies() error {
// Проверяем существование node_modules
nodeModulesPath := filepath.Join(q.config.Paths.Quartz, "node_modules")
if _, err := os.Stat(nodeModulesPath); os.IsNotExist(err) {
q.logger.Info("Installing npm dependencies...")
cmd := exec.Command("npm", "install")
cmd.Dir = q.config.Paths.Quartz
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to install npm dependencies: %w", err)
}
q.logger.Info("npm dependencies installed successfully")
} else {
q.logger.Debug("node_modules already exists, skipping npm install")
}
return nil
}

View File

@@ -0,0 +1,28 @@
package services
// BuildResult результат сборки сайта
type BuildResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
// BuildStatus статус сборки
type BuildStatus string
const (
BuildStatusPending BuildStatus = "pending"
BuildStatusRunning BuildStatus = "running"
BuildStatusCompleted BuildStatus = "completed"
BuildStatusFailed BuildStatus = "failed"
)
// BuildInfo детальная информация о сборке
type BuildInfo struct {
ID string `json:"id"`
Status BuildStatus `json:"status"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time,omitempty"`
Duration string `json:"duration,omitempty"`
Result BuildResult `json:"result,omitempty"`
}