forked from templates/template-go-backend
Sketch
This commit is contained in:
63
src/internal/config/base.go
Normal file
63
src/internal/config/base.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// LoadConfig загружает конфиг из .env (если есть) и окружения.
|
||||
func LoadConfig(logger *slog.Logger) (*Config, error) {
|
||||
_ = godotenv.Load() // необязательно фейлиться, если файла нет
|
||||
|
||||
cfg := &Config{
|
||||
Timezone: GetEnvAs("TIMEZONE", "UTC", ParseString),
|
||||
ServiceURL: GetEnvAs("SERVICE_URL", "http://localhost:8080", ParseString),
|
||||
LoggingConfig: LoggingConfig{
|
||||
Instance: logger,
|
||||
Level: GetEnvAs("LOG_LEVEL", "info", ParseString),
|
||||
},
|
||||
DatabaseConfig: FillDatabaseConfig(),
|
||||
}
|
||||
|
||||
printConfig(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// PrintConfig выводит конфигурацию (или любой другой struct) в виде таблички "KEY - VALUE".
|
||||
// Функция использует рефлексию для перебора полей структуры.
|
||||
func printConfig(cfg any) {
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "Loaded configuration:")
|
||||
fmt.Fprintln(w, "KEY\tVALUE")
|
||||
fmt.Fprintln(w, "----\t-----")
|
||||
|
||||
// Получаем reflect.Value объекта.
|
||||
v := reflect.ValueOf(cfg)
|
||||
// Если передан указатель, получаем значение, на которое он указывает.
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
t := v.Type()
|
||||
|
||||
// Перебираем все поля структуры.
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
fieldName := t.Field(i).Name
|
||||
fieldValue := v.Field(i).Interface()
|
||||
|
||||
// Если поле имеет тип time.Duration, выводим его в виде строки.
|
||||
if d, ok := fieldValue.(time.Duration); ok {
|
||||
fieldValue = d.String()
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s:\t%v\n", fieldName, fieldValue)
|
||||
}
|
||||
fmt.Fprintln(w, "----\t-----")
|
||||
w.Flush()
|
||||
}
|
||||
43
src/internal/config/database.go
Normal file
43
src/internal/config/database.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Kind string
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
UseTLS bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func FillDatabaseConfig() DatabaseConfig {
|
||||
return DatabaseConfig{
|
||||
Kind: GetEnvAs("DATABASE_KIND", "postgres", ParseString),
|
||||
Host: GetEnvAs("DATABASE_HOST", "localhost", ParseString),
|
||||
Port: GetEnvAs("DATABASE_PORT", "5432", ParseString),
|
||||
User: GetEnvAs("DATABASE_USER", "chudovo", ParseString),
|
||||
Password: GetEnvAs("DATABASE_PASS", "top_secret", ParseString),
|
||||
Name: GetEnvAs("DATABASE_NAME", "chudovo", ParseString),
|
||||
UseTLS: GetEnvAs("DATABASE_USETLS", false, ParseBool),
|
||||
}
|
||||
}
|
||||
|
||||
func GetDatabaseDSN(cfg *DatabaseConfig) string {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=%s",
|
||||
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name,
|
||||
)
|
||||
|
||||
// Если TLS отключен, добавляем параметр sslmode=disable
|
||||
if !cfg.UseTLS {
|
||||
dsn += " sslmode=disable"
|
||||
}
|
||||
|
||||
return dsn
|
||||
}
|
||||
96
src/internal/config/environment.go
Normal file
96
src/internal/config/environment.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Универсальная функция получения значения из окружения с парсером.
|
||||
func GetEnvAs[T any](key string, defaultVal T, parse func(string) (T, error)) T {
|
||||
raw, ok := os.LookupEnv(key)
|
||||
if !ok || strings.TrimSpace(raw) == "" {
|
||||
return defaultVal
|
||||
}
|
||||
v, err := parse(raw)
|
||||
if err != nil {
|
||||
// одна строка лога достаточно — не дублируем
|
||||
slog.Warn(fmt.Sprintf("cannot parse env %q, using default", key),
|
||||
"key", key, "raw", raw, "default", defaultVal, "error", err)
|
||||
return defaultVal
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/* ====== БАЗОВЫЕ АДАПТЕРЫ ====== */
|
||||
|
||||
func ParseString(s string) (string, error) { // просто возвращаем trimmed string
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
|
||||
func ParseBool(s string) (bool, error) {
|
||||
return strconv.ParseBool(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func ParseInt(s string) (int, error) {
|
||||
i64, err := strconv.ParseInt(strings.TrimSpace(s), 10, 0)
|
||||
return int(i64), err
|
||||
}
|
||||
|
||||
func ParseInt64(s string) (int64, error) {
|
||||
return strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
}
|
||||
|
||||
func ParseUint(s string) (uint, error) {
|
||||
u64, err := strconv.ParseUint(strings.TrimSpace(s), 10, 0)
|
||||
return uint(u64), err
|
||||
}
|
||||
|
||||
func ParseFloat64(s string) (float64, error) {
|
||||
return strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
}
|
||||
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
// поддерживает "150ms", "2s", "1m", "24h"
|
||||
return time.ParseDuration(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func ParseTimeRFC3339(s string) (time.Time, error) {
|
||||
return time.Parse(time.RFC3339, strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func ParseURL(s string) (*url.URL, error) {
|
||||
return url.Parse(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
/* ====== СПИСКИ (CSV/SEPARATOR) ====== */
|
||||
|
||||
// Универсальный адаптер для списков с произвольным парсером элемента.
|
||||
func MakeListParser[T any](sep string, itemParser func(string) (T, error)) func(string) ([]T, error) {
|
||||
return func(s string) ([]T, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
var zero []T
|
||||
return zero, nil
|
||||
}
|
||||
parts := strings.Split(s, sep)
|
||||
out := make([]T, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v, err := itemParser(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse list item %q: %w", p, err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Частые случаи:
|
||||
var ParseCSVStrings = MakeListParser[string](",", ParseString)
|
||||
var ParseCSVInts = MakeListParser[int](",", ParseInt)
|
||||
var ParseCSVFloat64 = MakeListParser[float64](",", ParseFloat64)
|
||||
17
src/internal/config/interface.go
Normal file
17
src/internal/config/interface.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package config
|
||||
|
||||
import "log/slog"
|
||||
|
||||
type LoggingConfig struct {
|
||||
Instance *slog.Logger
|
||||
Level string
|
||||
ShowCanDump bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Timezone string
|
||||
ServiceURL string
|
||||
|
||||
LoggingConfig
|
||||
DatabaseConfig
|
||||
}
|
||||
134
src/internal/gateway/handler.go
Normal file
134
src/internal/gateway/handler.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"backend/src/internal/config"
|
||||
"backend/src/logic"
|
||||
"backend/src/models"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// глобальный хаб соединений WebSocket
|
||||
var hub sync.Map
|
||||
|
||||
// Настройки для обновления соединения до WebSocket
|
||||
var upgrader = websocket.Upgrader{
|
||||
// разрешаем соединения с любых источников
|
||||
// (для продакшена стоит ограничить)
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// WebSocketHubResponse отправляет ответ отдельному приложению
|
||||
func WebSocketHubResponse(packet models.ClientPacket) {
|
||||
cid := packet.ClientID
|
||||
msg := packet.Payload
|
||||
value, ok := hub.Load(cid)
|
||||
if ok {
|
||||
conn := value.(*websocket.Conn)
|
||||
log.Printf("[WS] Sending message: %s", msg)
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
log.Printf("[WS] Send error: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[WS] Client <%s> unknown, skipping sending...", cid)
|
||||
}
|
||||
}
|
||||
|
||||
// Worker обрабатывает канал подписки и отправляет результат в WebSocket
|
||||
func Worker(ch models.ChannelOut) {
|
||||
log.Printf("[WS] Response worker is ready")
|
||||
for packet := range ch {
|
||||
WebSocketHubResponse(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчик для WebSocket соединения
|
||||
func WebSocketHandler(ctx context.Context, cfg *config.Config,
|
||||
w http.ResponseWriter, r *http.Request, bl *logic.Business) {
|
||||
logger := cfg.LoggingConfig.Instance
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
logger.Error("[WS] Unable to restart connection", "error", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// рассчитываем, что клиент присылает свой идентификатор, иначе создаём
|
||||
cid := r.URL.Query().Get("cid")
|
||||
if cid == "" {
|
||||
cid = uuid.New().String() // FIXME: или сбрасывать соединение
|
||||
}
|
||||
|
||||
// сохраняем соединение с клиентом в хабе
|
||||
hub.Store(cid, conn)
|
||||
logger.Info("[WS] Remote <%s> connected: %s", cid, conn.RemoteAddr())
|
||||
|
||||
// создаём канал для ответов в вебсокет
|
||||
wsResponseChannel := make(chan models.ClientPacket, 10000)
|
||||
|
||||
// Запускаем горутину, которая будет получать сообщения из канала и отправлять их клиенту.
|
||||
go Worker(wsResponseChannel)
|
||||
|
||||
// начинаем обработку сообщений от веб приложения
|
||||
for {
|
||||
messageType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
logger.Info("[WS] Remote <%s> disconnected: %v:", cid, err)
|
||||
hub.Delete(cid)
|
||||
break
|
||||
}
|
||||
|
||||
// получаем общие поля
|
||||
var commonFields models.BaseFieldT
|
||||
if err := json.Unmarshal(message, &commonFields); err != nil {
|
||||
log.Println("[WS] Unable unmarshal common fields:", err)
|
||||
continue
|
||||
}
|
||||
// определяем тип сообщения
|
||||
switch commonFields.Action {
|
||||
case "COMMAND1":
|
||||
logger.Info("[WS] COMMAND1 requested!")
|
||||
/*
|
||||
var request models.CoordinatorConfigurationRequestT
|
||||
if err := json.Unmarshal(message, &request); err != nil {
|
||||
logger.Error("[WS] Unable unmarshal CoordinatorConfigurationRequestT")
|
||||
continue
|
||||
}
|
||||
|
||||
payload, err := bl.GetCoordinatorConfiguration(request.OrganizationID)
|
||||
|
||||
var packet models.CoordinatorConfigurationResponseT
|
||||
if err != nil {
|
||||
packet = models.CoordinatorConfigurationResponseT{
|
||||
BaseFieldT: commonFields,
|
||||
Status: 200,
|
||||
Payload: *payload,
|
||||
}
|
||||
} else {
|
||||
packet = models.CoordinatorConfigurationResponseT{
|
||||
BaseFieldT: commonFields,
|
||||
Status: 400,
|
||||
Payload: models.CoordinatorConfigurationT{},
|
||||
}
|
||||
}
|
||||
data, _ := json.Marshal(packet)
|
||||
wsResponseChannel <- models.ClientPacket{ClientID: cid, Payload: data}
|
||||
*/
|
||||
default:
|
||||
if messageType == websocket.TextMessage {
|
||||
log.Printf("[WS] Got text message: %s", string(message))
|
||||
} else {
|
||||
log.Printf("[WS] Got unknown message: %d", messageType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/internal/gateway/ws_queue.go
Normal file
65
src/internal/gateway/ws_queue.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ClientPacket используется для передачи пакетов через Go каналы
|
||||
type ClientPacket struct {
|
||||
ClientID string
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
type ChannelIn chan<- ClientPacket
|
||||
type ChannelOut <-chan ClientPacket
|
||||
|
||||
type WebSocketQueue struct {
|
||||
ctx context.Context
|
||||
ch chan ClientPacket
|
||||
}
|
||||
|
||||
func NewWebSocketQueue(ctx context.Context) *WebSocketQueue {
|
||||
q := WebSocketQueue{
|
||||
ctx: ctx,
|
||||
ch: make(chan ClientPacket, 10000),
|
||||
}
|
||||
|
||||
// Запускаем горутину, которая будет получать сообщения из канала
|
||||
// и отправлять их клиенту.
|
||||
go q.runWorker()
|
||||
return &q
|
||||
}
|
||||
|
||||
// runWorker обрабатывает канал подписк и отправляет результат в WebSocket
|
||||
func (q *WebSocketQueue) runWorker() {
|
||||
// создаём канал для ответов в вебсокет
|
||||
log.Printf("[WS] Response worker is ready")
|
||||
for packet := range q.ch {
|
||||
q.realSend(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Send на самом деле помещает пакет в очередь отправки, которая затем
|
||||
// разгребается с помощью WorkerOfQueuedResponsesToWS.
|
||||
func (q *WebSocketQueue) Send(packet ClientPacket) {
|
||||
q.ch <- packet
|
||||
}
|
||||
|
||||
// WebSocketHubResponse отправляет ответ отдельному приложению
|
||||
func (q *WebSocketQueue) realSend(packet ClientPacket) {
|
||||
cid := packet.ClientID
|
||||
msg := packet.Payload
|
||||
value, ok := hub.Load(cid)
|
||||
if ok {
|
||||
conn := value.(*websocket.Conn)
|
||||
log.Printf("[WS] Sending message: %s", msg)
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
log.Printf("[WS] Send error: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[WS] Client <%s> unknown, skipping sending...", cid)
|
||||
}
|
||||
}
|
||||
18
src/logic/business.go
Normal file
18
src/logic/business.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"backend/ent"
|
||||
"context"
|
||||
)
|
||||
|
||||
type Business struct {
|
||||
ctx context.Context
|
||||
db *ent.Client
|
||||
}
|
||||
|
||||
func NewBusinessLogic(ctx context.Context, client *ent.Client) *Business {
|
||||
return &Business{
|
||||
ctx: ctx,
|
||||
db: client,
|
||||
}
|
||||
}
|
||||
89
src/main.go
Normal file
89
src/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"backend/ent"
|
||||
"backend/src/internal/config"
|
||||
"backend/src/internal/gateway"
|
||||
"backend/src/logic"
|
||||
|
||||
_ "github.com/lib/pq" // побочный импорт драйвера PostgreSQL
|
||||
"github.com/rs/cors"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "Backend"
|
||||
AppVersion = "1.0.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
logger := slog.Default()
|
||||
// slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
|
||||
logger.Info(fmt.Sprintf("Starting %s version %s\n", AppName, AppVersion))
|
||||
|
||||
cfg, err := config.LoadConfig(logger)
|
||||
if err != nil {
|
||||
logger.Error("Configuration loading error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// создаем контекст с отменой для управления жизненным циклом сервиса.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// подключаемся к базе данных
|
||||
dsn := config.GetDatabaseDSN(&cfg.DatabaseConfig)
|
||||
db, err := ent.Open(cfg.DatabaseConfig.Kind, dsn)
|
||||
if err != nil {
|
||||
logger.Error("Failed opening connection to postgres", "error", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Применяем миграции
|
||||
if err := db.Schema.Create(ctx); err != nil {
|
||||
logger.Error("Failed creating schema resources", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// инициализируем бизнес логику
|
||||
business := logic.NewBusinessLogic(ctx, db)
|
||||
|
||||
// регистрируем обработчик WebSocket по адресу /ws
|
||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
gateway.WebSocketHandler(ctx, cfg, w, r, business)
|
||||
})
|
||||
|
||||
// создаём CORS middleware
|
||||
corsMiddleware := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:4200"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
||||
AllowCredentials: true,
|
||||
})
|
||||
|
||||
// оборачиваем стандартный mux (DefaultServeMux)
|
||||
handler := corsMiddleware.Handler(http.DefaultServeMux)
|
||||
|
||||
// адрес сервера должен быть без схемы
|
||||
clearURL := cfg.ServiceURL
|
||||
if strings.Contains(clearURL, "://") {
|
||||
if u, err := url.Parse(clearURL); err == nil && u.Host != "" {
|
||||
clearURL = u.Host
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("WebSocket server is on", cfg.ServiceURL)
|
||||
if err := http.ListenAndServe(clearURL, handler); err != nil {
|
||||
logger.Error("WebSocket server is unable to start", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
7
src/models/base.go
Normal file
7
src/models/base.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
type BaseFieldT struct {
|
||||
TaskID int `json:"taskId" binding:"required"`
|
||||
Kind string `json:"kind" binding:"required"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
}
|
||||
27
src/models/packets.go
Normal file
27
src/models/packets.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// ClientPacket используется для передачи пакетов через Go каналы
|
||||
type ClientPacket struct {
|
||||
ClientID string
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// ClientRequest используется для передачи запросов от клиента
|
||||
type ClientRequest struct {
|
||||
TaskID uuid.UUID `json:"taskId" binding:"required"`
|
||||
Kind string `json:"kind" binding:"required"`
|
||||
Action string `json:"action" binding:"required"`
|
||||
}
|
||||
|
||||
// ClientResponse используется для передачи ответов клиенту
|
||||
type ClientResponse struct {
|
||||
ClientRequest
|
||||
Status int `json:"status" binding:"required"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelIn chan<- ClientPacket
|
||||
type ChannelOut <-chan ClientPacket
|
||||
Reference in New Issue
Block a user