• Я зарабатываю 100 000 RUB / месяц на этом сайте!

    А знаешь как? Я всего-лишь публикую (создаю темы), а админ мне платит. Трачу деньги на мороженое, робуксы и сервера в Minecraft. А ещё на паль из Китая. 

    Хочешь так же? Пиши и узнавай условия: https://t.me/alex_redact
    Реклама: https://t.me/yougame_official

Гайд Пишем простую админ-панель для вашего чита на Go #1 | Архитектура, БД, Авторизация

  • Автор темы Автор темы shineus
  • Дата начала Дата начала
Пользователь
Пользователь
Статус
Оффлайн
Регистрация
31 Мар 2024
Сообщения
174
Реакции
44
Приветствую друзья, таких гайдов множество, но я решил что лишним она не будет.

Очень важное предисловие, это только первая часть темы, их будет 2-3 в общей сумме, разделить я решил потому что очень много букв, следующая тема выйдет через пару дней буквально, долго ждать не придётся. В каждой статье я буду прикреплять итоговый архив этой части статьи.

А так же ещё один важный момент, я не фронтендер, и за это очень плохо шарю из за этого код фронтенда будет так себе, но рабочий

Но для тех кто всё ещё не понял из названия темы, что тут будет происходить:
Мы с вами в рамках этой темы (может нескольких тем, посмотрим) напишем простенькую админ-панель для ваших проектов

А конкретнее мы с вами разработаем:
- Простую систему авторизации в панели
- Системы генерации ключей
- Работа с базой данных
- АПИ для вашего лаунчера
- Настроим деплой, метрику и всё такое.

В этой же части из всего этого мы разберём:
1. Выбор технологий которые мы будем использовать
2. Создание проекта и описывание его структуры
3. Написание базы данных
4. Написание первых запросов на БД
5. Создание простой авторизации и нарисуем страницу авторизации


Но с чего же нам начать?
А начать надо с архитектуры проекта. Для тех кто не знает, архитектура проекта - это планирование структуры проекта, подбор технологий для использования в продукте и прочее.

Начнем то мы как раз таки с подбора технологий:
1. Язык - Из названия темы тут и так понятно, что мы будем писать на Golang, почему? Потому что этот язык простой в использовании, при этом быстрый при правильном использовании.
2. База данных - Тут я долго думать не стал, выбрал то что больше всего люблю и чаще всего использую, PostgreSQL - Достаточно быстрая и удобная СУБД.
3. Хранилище - Я долго думал, нужно ли тут это нам. И пришёл к выводу что если не объясню я, то ваи никто ещё долго не объяснит об этом ничего. По этому мы возьмём
Пожалуйста, авторизуйтесь для просмотра ссылки.
в качестве объектного хранилища, у него есть совместимость с S3 по этому будет удобно взаимодействовать с ним
4. Библиотеки - Так как у нашего проекта вряд ли будет тысячи, сотни тысяч RPS, в качестве веб фреймворка мы возьмём
Пожалуйста, авторизуйтесь для просмотра ссылки.
, у него есть весь нужный нам набор функций, включая SSR. Для базы данных мы возьмём библиотеку
Пожалуйста, авторизуйтесь для просмотра ссылки.
и
Пожалуйста, авторизуйтесь для просмотра ссылки.
, pgx очень удобен для использования особенно вместе со scany, будет очень легко взаимодествовать с БД. Для работы с MinIO мы возьмём их же библиотеку
Пожалуйста, авторизуйтесь для просмотра ссылки.
. Для миграций базы данных будем использовать
Пожалуйста, авторизуйтесь для просмотра ссылки.
, а для конфигурации мы возьмёмь библиотеку
Пожалуйста, авторизуйтесь для просмотра ссылки.
и будем использовать toml
5. Метрика - Для этого мы возьмём самое известное и удобное Prometheus и Grafana, о том как это настриоть я расскажу чуть попозже
6. Деплой - Ну тут всё просто, будем использовать Docker, а так же gchr.io (Github actions)

И так мы определились с технологиями что мы будем использовать в проекте. Теперь можно приступать к разработке
Я буду использовать Goland от Jetbrains для разработки, но вам буду писать команды для терминала которые будем использовать
Для начала нам надо создать проект в целом. Для этого надо использовать команду:
go mod init yougame-backend

После того как создали проект, давайте сразу организуем его структуру. С непривычки будет выглядеть сложно и не понятно, но в процессе данной статьи я вам объясню что и как тут устроено. Чтобы не тянуть время, вот так должна выглядеть структура:

Структура проекта:
Expand Collapse Copy
├── cmd
│   ├── migration
│   └── server
├── internal
│   ├── app
│   ├── delivery
│   │   ├── dto
│   │   └── http
│   ├── domain
│   │   ├── models
│   │   └── repositories
│   └── infrastructure
│       ├── db
│       └── storage
├── migrations
├── config
└── pkg
    ├── config
    └── logger

Тут всё мы распределили аккуратно и удобно, работать с этим. Теперь нам надо сделать базу данных

Для этого нам надо установить goose, делается это следующей командой:
go install github.com/pressly/goose/v3/cmd/goose@latest

После того как он установится проверим работоспособность
goose -version

Вам должно вывести что то вроде этого:
goose version: v3.24.1

Когда мы понимаем что goose установлен, создадим наши файлы для миграций

Я уточню, что я делаю всё на ОС Linux Mint и команды могут отличаться от ваших

Делаем по порядку, переходим в папку с нашими миграциями:
cd migrations

Дальше нам надо создать файлы миграций, делается это с помощью команды:
goose create add_users_table sql

Но помимо этого нам нужно создать всё остальное, я лично разделяю файлы миграций на несколько разных, тут просто потому что мне так удобно, так что создадим остальные
goose create add_keys_table sql
goose create add_product_table sql
goose create add_ban_table sql

В итоге у вас в папке migrations должны появится эти файлы:
1.png


Этих таблиц нам должно хватить для минимальной админ панели для вашего чита.

add_users_table.sql - Тут будет таблица с пользователями, которые имеют доступ в админ панель
add_keys_table.sql - Тут будет таблица с ключами, которые пользователи будут использовать для активации
add_product_table.sql - Тут будет таблица с нашими продуктами, для которых мы и будем генерировать ключи
add_ban_table.sql - Тут будет таблица с заблокированными пользователями, по hardware id

Ну а теперь нам надо написать сами таблицы, и начем мы с add_users_table, она будет минимальной
add_users_table migration:
Expand Collapse Copy
-- +goose Up
-- +goose StatementBegin
CREATE TABLE users(
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    password_hash VARCHAR(60) NOT NULL,
);

COMMENT ON COLUMN users.username IS 'Unique username of the user';
COMMENT ON COLUMN users.password_hash IS 'Hashed password using bcrypt';
INSERT INTO users (username, password_hash) VALUES ('admin', '$2a$12$eoTNJ6kFwfujsNTJvRNmvuAFoTZeKXc8m5y9UH1vE50wP2I.Z06pi');

CREATE UNIQUE INDEX CONCURRENTLY users_username_index ON users (username);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE users;
DROP INDEX users_username_index;
-- +goose StatementEnd

В данном коде мы создаем таблицу users в которой есть поля:
1. id - Уникальный айди пользователя, он будет заполняться самостоятельно при создании пользователя
2. username - Юзернейм пользователя по которому будет происходить логин
3. passsword_hash - Хешированый пароль, его мы будем хранить в bcrypt

Помимо этого мы добавили коммантарии к нашим полям username и password_hash, так будет легче для документации и понимания
Дальше мы создаем нашего первого юзера в с данными admin:admin, тут я захардкодил пароль, лучше так не делать
Всё тут помечено с помощью +goose Up и +goose Down чтобы goose мог отличать что выполнять при командах up и down соответственно

Теперь нам надо разработать таблицу продуктов
add_product_table migration:
Expand Collapse Copy
-- +goose Up
-- +goose StatementBegin
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    process_name VARCHAR(255) NOT NULL,
    object_path VARCHAR(255) NOT NULL,
    status SMALLINT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
RETURN NEW;
END;

$$ LANGUAGE plpgsql;
CREATE TRIGGER update_products_updated_at
    BEFORE UPDATE ON products
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TRIGGER IF EXISTS update_products_updated_at ON products;
DROP FUNCTION IF EXISTS update_updated_at_column;
DROP TABLE products;
-- +goose StatementEnd

Тут думаю не надо объяснять что происходит в таблице, но есть интересный момент, а именно триггер и функция
Так конечно лучше не делать, ибо зачем выносить логику в БД если можно сделать её в коде, но всё же я показываю для вас что так можно
В данном случае я сделал простую функцию, которая будет сама обновлять updated_at при вызове триггера update_products_updated_at. Триггер же будет срабатывать автоматически при каком либо обновлении в нашей таблице. В основном это нужно при обновлении статуса.

Теперь мы можем заполнить нашу таблицу add_keys_table
add_keys_table migration:
Expand Collapse Copy
-- +goose Up
-- +goose StatementBegin
CREATE TABLE keys (
    id SERIAL PRIMARY KEY,
    key TEXT NOT NULL UNIQUE,
    product_id INTEGER NOT NULL,
    status SMALLINT NOT NULL,
    hardware_id TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    CONSTRAINT keys_product_id_fkey FOREIGN KEY (product_id)
        REFERENCES products(id)
        ON DELETE CASCADE
);

CREATE OR REPLACE FUNCTION update_keys_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
RETURN NEW;
END;

$$ LANGUAGE plpgsql;
CREATE TRIGGER update_keys_updated_at
    BEFORE UPDATE ON keys
    FOR EACH ROW
    EXECUTE FUNCTION update_keys_updated_at_column();
CREATE INDEX idx_keys_product_id ON keys(product_id);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_keys_product_id;
DROP TRIGGER IF EXISTS update_keys_updated_at ON keys;
DROP FUNCTION IF EXISTS update_keys_updated_at_column;
DROP TABLE keys;
-- +goose StatementEnd

В данном случае все точно так же понятно должно быть исходя из объяснений прошлых таблиц, но есть пара уточнений. Например тут мы создаем связь записи ключа с записями продуктов, сделан это для того, чтобы если мы удалим продукт, то все связанные с ним ключи так же удаляться. Это надо для того, чтобы не оставлять в БД лишних записей, ибо зачем они нам нужны будут если продукта уже не будет существовать? А так же я добавил тут индекс, который будет полезен при команде типа SELECT * FROM keys WHERE product_id = 1; С этим наш код будет работать чуть быстрее

И осталась нам миграция add_ban_table
add_ban_table migration:
Expand Collapse Copy
-- +goose Up
-- +goose StatementBegin
CREATE TABLE ban (
    id SERIAL PRIMARY KEY,
    hardware_id TEXT NOT NULL UNIQUE,
    reason TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

CREATE OR REPLACE FUNCTION update_keys_status_on_ban()
RETURNS TRIGGER AS $$
BEGIN
UPDATE keys
SET status = 3
WHERE hardware_id = NEW.hardware_id;
RETURN NEW;
END;

$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_keys_status_on_ban
    AFTER INSERT ON ban
    FOR EACH ROW
    EXECUTE FUNCTION update_keys_status_on_ban();
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TRIGGER IF EXISTS trigger_update_keys_status_on_ban ON ban;
DROP FUNCTION IF EXISTS update_keys_status_on_ban;
DROP TABLE ban;
-- +goose StatementEnd


Тут все так же просто по самой таблице, а так же есть функция и триггер которая при INSERT в таблицу бана поменяет статус у всех ключей с таким же hardware ID
На этом мы закончили с разработкой базы данных. Возможно в процессе разработки мы её доработаем, но пока что оставим так.

Теперь нам надо перейти к разработке самой админки. И что логично начнем мы с конфига
Для начала, нам нужно установить библиотеку что мы хотим использовать для конфига, о ней я писал в начале статьи:
go get github.com/spf13/viper

Дальше мы создаём файл config.go по пути pkg/config/config.go

В нем мы должны написать такой код:
config.go:
Expand Collapse Copy
package config

import (
    "fmt"
    "github.com/spf13/viper"
)

type Config struct {
    Postgres PostgresConfig
}

type PostgresConfig struct {
    Host     string `mapstructure:"host"`
    Port     int    `mapstructure:"port"`
    User     string `mapstructure:"user"`
    Password string `mapstructure:"password"`
    Database string `mapstructure:"database"`

}

func (cfg PostgresConfig) DSN() string {
    return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database)
}

func Load() (*Config, error) {

    viper.SetConfigName("config")
    viper.SetConfigType("toml")
    viper.AddConfigPath("./config")

    if err := viper.ReadInConfig(); err != nil {
        return nil, err
    }

    var config Config
    if err := viper.Unmarshal(&config); err != nil {
        return nil, err
    }

    return &config, nil
}

Что мы тут видим? Мы создаем 2 структуры, первая это общий Config оттуда мы получаем доступ к конфигурации БД. А так же создаём PostgresConfig где мы храним все данные о нашей БД, а так же мы создаем функцию относящуюся к PostgresConfig где заполняем строку для подключения к БД, она нам понадобиться в будущем, называется функция DSN или же Data Source Name. При вызове этой функции нам отдаст строку: postgres://postgres:postgress@localhost:5432/yougame-panel?sslmode=disable

Теперь нам надо накидать сам конфиг, пока что он довольно простой

Конфиг нам надо поместить по пути config/config.toml. А его содержимое:
config.toml:
Expand Collapse Copy
[postgres]
host = "localhost"
port = 5432
user = "postgres"
password = "postgress"
database = "yougame-panel"

Теперь когда мы сделали конфигурацию для нашей базы данных, давайте создадим подключение к ней в целом. Для этого сначала установим pgx, небольшое уточнение мы будем использовать в коде Connection Pool, это нужно чтобы не надо было закрывать и открывать подключение к БД при новвых запросах.

Переходим непосредственно к установке:
go get github.com/jackc/pgx/v5/pgxpool

После того как установилось, нам надо по пути internal/infrastructure/db/ создать файл: postgres.go

В нем у нас будет всё для начала работы с нашей БД:
postgres.go:
Expand Collapse Copy
package db

import (
    "context"
    "github.com/jackc/pgx/v5/pgxpool"
    "yougame-backend/pkg/config"
)

type Postgres struct {
    conn *pgxpool.Pool
}

func NewPostgres(ctx context.Context, cfg *config.PostgresConfig) (*Postgres, error) {
    poolConfig, err := pgxpool.ParseConfig(cfg.DSN())
    if err != nil {
        return nil, err
    }

    conn, err := pgxpool.NewWithConfig(ctx, poolConfig)
    if err != nil {
        return nil, err
    }
    return &Postgres{conn: conn}, nil
}

func (p *Postgres) Close() {
    p.conn.Close()
}

func (p *Postgres) Ping(ctx context.Context) error {
    return p.conn.Ping(ctx)
}

Тут у нас есть 3 метода:
1. Метод который можно назвать конструктором, он у нас собственно и создаёт подключение к базе данных
2. Метод который у нас закрывает подключение к БД, он нам нужен будет для корректного завершения нашего бэкенда
3. Метод Ping нужен для проверки соединения с БД, он нужен нам при инициализации и при каких нибудь сложных запросах чтобы убедиться что база данных работает нормально

Пока что нам хватит этого, запросы к БД мы будем писать в других файлах, но до этого нам ещё далеко

Теперь давайте напишем наш софт для того, чтобы сделать миграцию данных. Это конечно можно сделать с помощью самого goose, в целом то мы и не уходим от его использования, а просто оборачиваем в свой код. Мне лично так удобнее работать, потому что всегда можно продебажить что пошло не так.

По пути cmd/migration создаем файл main.go

Далее устанавливаем библиотеку goose:
go get github.com/pressly/goose/v3

Данный софт будет работать с тем же конфигом что и наш основной бэкенд, так как он находится в том же проекте.

Код будет выглядеть у нас так:
migration/main.go:
Expand Collapse Copy
package main

import (

    "context"
    "flag"
    "fmt"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/jackc/pgx/v5/stdlib"
    "github.com/pressly/goose/v3"
    "os"
    "yougame-backend/pkg/config"
)

func main() {
    up := flag.Bool("up", false, "Run migrations up")
    down := flag.Bool("down", false, "Run migrations down")

    flag.Parse()

    cfg, err := config.Load()
    if err != nil {
        panic(err)
    }

    if *up && *down {
        panic("cannot run both --up and --down migrations")
    }

    poolConfig, err := pgxpool.ParseConfig(cfg.Postgres.DSN())

    pool, err := pgxpool.NewWithConfig(context.Background(), poolConfig)
    if err != nil {
        panic("failed to connect to database: " + err.Error())
    }

    defer pool.Close()

    db := stdlib.OpenDBFromPool(pool)
    if err := goose.SetDialect("postgres"); err != nil {
        panic("failed to set dialect: " + err.Error())
    }

    migrationsPath := "./migrations"

    var migrateErr error
    if *up {
        migrateErr = goose.Up(db, migrationsPath)
    } else if *down {
        migrateErr = goose.Down(db, migrationsPath)
    } else {
        fmt.Println("migration failed: no command specified")
        os.Exit(1)
    }

    if migrateErr != nil {
        fmt.Println("Migration failed: ", migrateErr)
    } else {
        fmt.Println("Migrations applied successfully")
    }
}

Тут всё просто, мы подключаемся к бд, получаем подключение в виде стандартной либы, устанавливаем диалект подключения, и в зависимости от команды (--up или --down) выполняем команду. Up у нас отвечает за поднятие миграций, а Down за то чтоб их удалить / откатить

выполнить команду можно с помощью
go run cmd/migration/main.go --up

После успешного выполнения данной команды вам должно выдать:
результат выполнения:
Expand Collapse Copy
$ go run cmd/migration/main.go --up
2025/06/26 21:38:31 OK   20250626133026_add_users_table.sql (15.62ms)
2025/06/26 21:38:31 OK   20250626133208_add_product_table.sql (15.71ms)
2025/06/26 21:38:31 OK   20250626133209_add_keys_table.sql (25.21ms)
2025/06/26 21:38:31 OK   20250626133217_add_ban_table.sql (18.46ms)
2025/06/26 21:38:31 goose: successfully migrated database to version: 20250626133217
Migrations applied successfully

Теперь установим gofiber, так как сейчас он нам нужен будет
go get -u github.com/gofiber/fiber/v2

Тут уже надо написать наш файл инициализации http handler

Будет он по пути internal/delivery/http/http.go
http.go:
Expand Collapse Copy
package http

import (
    "github.com/gofiber/fiber/v2"
    "yougame-backend/internal/infrastructure/db"
    "yougame-backend/pkg/config"
)

type Handler struct {
    Fiber *fiber.App
    db    *db.Postgres
    cfg   *config.Config
}


func NewHttpHandler(db *db.Postgres, cfg *config.Config) *Handler {
    app := fiber.New(fiber.Config{})

    return &Handler{
        Fiber: app,
        db:    db,
        cfg:   cfg,
    }
}

func (h *Handler) Start() error {
    return h.Fiber.Listen(":3000")
}

func (h *Handler) Stop() error {
    return h.Fiber.Shutdown()
}

func (h *Handler) RegisterRoutes() {
    h.Fiber.Get("/", func(c *fiber.Ctx) error {
        return c.JSON(fiber.Map{"hello": "world"})
    })
}

Тут мы просто передаем параметры в нашу структуру Handler. Функция Start которая отвечает за сам запуск HTTP сервера и функция Stop для более корректной остановки сервера. Так же в Register Routes мы добавили простой метод который при гет запросе вернёт нам JSON строку с ответом hello: world

Сейчас нам надо написать инициализацию нашего приложения, создаем файл по пути internal/app/app.go и пишем:
internal/app/app.go:
Expand Collapse Copy
package app

import (
    "context"
    "yougame-backend/internal/delivery/http"
    "yougame-backend/internal/infrastructure/db"
    "yougame-backend/pkg/config"
)

type App struct {
    HttpHandler *http.Handler
    DB          *db.Postgres
    Cfg         *config.Config
}

func New(ctx context.Context) (*App, error) {
    cfg, err := config.Load()
    if err != nil {
        return nil, err
    }

    postgres, err := db.NewPostgres(ctx, &cfg.Postgres)
    if err != nil {
        return nil, err
    }

    err = postgres.Ping(ctx)
    if err != nil {
        return nil, err
    }

    httpHandler := http.NewHttpHandler(postgres, cfg)
    httpHandler.RegisterRoutes()

    return &App{
        HttpHandler: httpHandler,
        DB:          postgres,
        Cfg:         cfg,
    }, nil
}

func (app *App) Start() error {
    return app.HttpHandler.Start()
}

func (app *App) Shutdown() error {
    app.DB.Close()
    return app.HttpHandler.Stop()
}

Тут мы инициализируем наше приложение, сначала парсим конфиг. Если всё успешно, то подключаемся к базе данных и делаем пинг для того, чтобы верифицировать что подключение действительно установилось, а после мы вызываем наш конструктор для HttpHandler и передаём ему созданные элементы, а так же регистрируем наши роуты

Функция Start отвечает за запуск нашего веб сервера, а Shutdown для адекватной остановки сервера.

Теперь мы можем написать наш main файл сервера. Создаем его по пути cmd/server/main.go и пишем:
cmd/server/main.go:
Expand Collapse Copy
package main

import (
    "context"
    "fmt"
    "yougame-backend/internal/app"
)

func main() {
    application, err := app.New(context.Background())

    if err != nil {
        panic(err)
    }

    defer func(application *app.App) {
        err := application.Shutdown()
        if err != nil {
            fmt.Println("Failed to shutdown application: ", err)
        }
    }(application)

    if err := application.Start(); err != nil {
        panic(err)
    }
}

Код мейна у нас вряд ли изменится когда нибудь, так что больше к нему мы не вернёмся, а вот к файлу app.go нам придётся вернуться ещё не пару раз точно

Теперь мы можем запустить наш сервер, напомню что перед тем как запускать вам надо установить PostgreSQL, создать там базу данных и запустить миграцию.

Запускаем сервер командой
go run cmd/server/main.go

При успешном запуске нам выдаст:
2.png


Теперь мы можем попробовать постучаться на наш сервер
Пожалуйста, авторизуйтесь для просмотра ссылки.

И при успешном запросе мы сможем увидеть:
3.png


Это ответ от нашего роута который мы создали в http.go

Ну а теперь перейдём к финальному, для данной части статьи, моменту. Авторизация
Для этого нам сначала надо установить scany:
go get github.com/georgysavva/scany/v2/pgxscan

После установки начнем писать код, для начала создадим нашу модель, internal/domain/models/user.go:
internal/domain/models/user.go:
Expand Collapse Copy
package models

type User struct {
    ID           int    `db:"id"`
    Username     string `db:"username"`
    PasswordHash string `db:"password_hash"`
}
Эта модель нужна нам, для того чтобы парсить данные из БД в структуру корректно, с помощью scany

После этого, нам надо файл internal/domain/repositories/user_repository.go:
Код:
Expand Collapse Copy
package repositories

import (
    "context"
    "yougame-backend/internal/domain/models"
)

type UserRepository interface {
    GetUserByUsername(ctx context.Context, username string) (*models.User, error)
}

Тут мы создаём интерфейс в котором определяет набор методов (в данный момент один метод), но без их реализации. Он нам нужен будет в наших ручках

Дальше нам надо сделать файл, в котором будет описана реализация данного интерфейса. internal/infrastructure/db/user_repository.go:
internal/infrastructure/db/user_repository.go:
Expand Collapse Copy
package db

import (
    "context"
    "fmt"
    "github.com/georgysavva/scany/v2/pgxscan"
    "yougame-backend/internal/domain/models"
    "yougame-backend/internal/domain/repositories"
)

type UserRepository struct {
    postgres *Postgres
}

func NewUserRepository(postgres *Postgres) repositories.UserRepository {
    return &UserRepository{postgres: postgres}
}

func (p *UserRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
    query := `
    SELECT * FROM users
    WHERE username = $1
    `

    var user models.User
    err := pgxscan.Get(ctx, p.postgres.Pgpool, &user, query, username)
    if err != nil {
        // TODO: Log error
        fmt.Println("GetUserByUsername", err)
        return nil, err
    }

    return &user, nil
}

Мы создаём структуру UserRepository в которой хранится доступ к нашей БД. После чего передаём её через конструктор. А так же создаём реализацию нашего метода в интерфейсе GetUserByUsername. Мы составляем запрос который получит пользователя с юзернеймом что мы передаём в аргументы, далее мы создаём пустую переменную user которая относится к нашей модели, и вызываем pgxscan.Get который выполнит этот запрос и заполнит нашу структуру. Понятное дело мы проверяем на ошибку, мало ли. После чего возвращаем нашу модель с заполненым юзером который нам нужен.

Теперь нам нужно создать нам dto для юзера по пути: internal/delivery/dto/request/user_request.go:
internal/delivery/dto/request/user_request.go:
Expand Collapse Copy
package request

type LoginUserRequest struct {
    Username string `json:"username"`
    Password string `json:"password"`
}

Это нам нужно для того, чтобы обрабатывать запрос на логин

После того как мы это все сделали, можно написать обработчик запроса на логин. На данный момент он будет мега простой, если тема зайдёт то мы будем расширять весь этот код и делать его более безопасным, но да ладно, создаём файл: internal/delivery/http/user/handlers.go:
internal/delivery/http/user/handlers.go:
Expand Collapse Copy
package user

import (
    "github.com/gofiber/fiber/v2"
    "golang.org/x/crypto/bcrypt"
    "yougame-backend/internal/delivery/dto/request"
    "yougame-backend/internal/domain/repositories"
)

type Handler struct {
    repo repositories.UserRepository
}

func NewUserHandler(repo repositories.UserRepository) *Handler {
    return &Handler{repo: repo}
}

func (h *Handler) LoginUser(c *fiber.Ctx) error {
    req := new(request.LoginUserRequest)

    if err := c.BodyParser(req); err != nil {
        return c.Status(fiber.StatusBadRequest).
            JSON(fiber.Map{
                "error": "Invalid request body",
            })
    }

    // Get user info from database by username
    user, err := h.repo.GetUserByUsername(c.Context(), req.Username)
    if err != nil {
        return c.Status(fiber.StatusNotFound).
            JSON(fiber.Map{
                "error": "User not found",
            })
    }

    // Compare user password with hash password in database
    err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
    if err != nil {
        return c.Status(fiber.StatusBadRequest).
            JSON(fiber.Map{
                "error": "Invalid password",
            })
    }

    return c.Status(fiber.StatusOK).
        JSON(fiber.Map{
            "status": "ok",
            "user":   user.Username,
        })
}

У нас есть структура Handler, которая хранит в себе наш репозиторий. Инициализируем всё в конструкторе, и делаем функцию которая будет хендлить наш логин. Мы выделяем память для нашей структуры который мы сделали в dto, после чего мы парсим наш Body и заполняем эту структуру, как только структура заполнена мы вызываем h.repo.GetUserByUsername для того чтобы получить нашего пользователя логин которого получили из Body. Если юзер корректно получается, мы должны сверить пароль который передали в Body с тем что есть у нас в БД в виде хеша. Для этого в Go предусмотрен пакет crypto и в нём bcrypt. Мы вызываем просто bcrypt.CompareHashAndPassword - он проверяет является ли данный пароль и хеш одним и тем же. Если всё хорошо, то мы возвращаем статус ок и наш юзернейм.

Осталось совсем чуть чуть. Возвращаемся к файлу internal/delivery/http/http.go.
В структуру Handler тут нам нужно добавить наш user Handler -
internal/delivery/http/http.go Handler struct:
Expand Collapse Copy
type Handler struct {
    Fiber *fiber.App
    db    *db.Postgres
    cfg   *config.Config

    // Handlers
    userHandler *user.Handler
}

Дальше в конструкторе NewHttpHandler нам нужно добавить инициализацию репозитория и хендлера юзера, а так же заполнить структуру до конца:
internal/delivery/http/http.go NewHttpHandler:
Expand Collapse Copy
func NewHttpHandler(postgres *db.Postgres, cfg *config.Config) *Handler {

    app := fiber.New(fiber.Config{})

    userRepo := db.NewUserRepository(postgres)
    userHandler := user.NewUserHandler(userRepo)

    return &Handler{
        Fiber: app,
        db:    postgres,
        cfg:   cfg,

        // Handlers
        userHandler: userHandler,
    }
}

После чего в RegisterRoutes мы должны зарегистрировать нашу ручку для того, чтобы была возможность войти в аккаунт:
internal/delivery/http/http.go:
Expand Collapse Copy
func (h *Handler) RegisterRoutes() {

    api := h.Fiber.Group("/api")

    userApi := api.Group("/user")
    userApi.Post("/login", h.userHandler.LoginUser)

}

Я группировал всё это в общую группу /api и подгруппу /user, что в итоге выдаст нам localhost:3000/api/user, дабы отделить апи от остального что нам надо будет.

Теперь мы можем запустить наш сервер. При успешном запуске нам выдаст:
1751577157333.png


Видим что есть у нас 1 хендлер, это наш /api/user/login. Теперь нам надо это протестить
Отправим через терминал простой запрос:
$ curl -X POST -H "Content-Type: application/json" http://localhost:3000/api/user/login -d '{"username": "admin", "password": "admin"}'

И в ответ мы должны получить:
{"status":"ok","user":"admin"}

Значит всё ок, и наш бэкенд и БД отрабатывают. Это успех. Для тестов вы можете поменять пароль в запросе или же юзернейм, он вам будет отдавать ошибки, которые мы описали в нашем user handler

На этом 1 часть статей завершена, завтра же приступлю к продолжению этой статьи, тут получилось много букв как вы можете заметить, но я очень старался описывать всё максимально подробно. Я так же прикрепил исходник этой части в файлах, так что вы можете просто скачать и потыкать это всё самостоятельно.
 

Вложения

Слив yougame-backend? :roflanEbalo:
 
привет, спасибо за качественную статью. хотел у тебя спросить работал ли ты с sqlx? если да, то были ли причины не использовать его в этом проекте? специфического функционала постгре (listen/notify, jsonb etc.) я не заметил, поэтому возможно если его использование в проекте не планируется, то было бы лучше заюзать sqlx с пулом от `database/sql` вместо pgxpool, учитывая насколько удобно можно подменять бдшки сохраняя один и тот же интерфейс? если была причина по которой ты выбрал pgxpool вместо sqlx то дай знать пожалуйста, никогда не пользовался этой библиотекой, поэтому не особо в курсе за её плюсы и минусы
Так как у нашего проекта вряд ли будет тысячи, сотни тысяч RPS
исходя из контента статьи, я понимаю что pgx вместо sqlx был выбран не из-за скорости. я на 100% согласен что любая лишняя абстракция - трата скорости, но в целом я думаю что при обращении к бдшке мы больше переживаем за RTT, нежели за лишние микросекунды на дополнительную абстракцию, поэтому если сервер не собирается хендлить сотни тысяч RPS - было бы отлично использовать более гибкий инструментарий


ещё пара моментов по поводу архитектуры:

1. UserRepository принимает в себя Postgres, в таком случае возможно стоило бы ренеймнуть структурку в PostgresUserRepository (либо вместо Postgres в аргументах передавать унифицированный под разные БД интерфейс, к примеру sqlx.DB, и тогда уже пассить его в репозитории, и при необходимости подменить сам driverName в коннекте от sqlx на "mysql" или "postgres")
2. я думаю что в чистой архитектуре всё же лучше компоновать компоненты в app пакете, соответственно не передавать в `NewHttpHandler(postgres *db.Postgres...)` и там уже создавать UserRepo, а прямо в app.go создавать репозитории, и уже их передавать меж слоями. что-бы была одна точка входа, и одна точка для изменений

и по мелочи, мне кажется в 2025 стоит переходить на argon2id вместо bcrypt-а, особенно учитывая сферу "админ-панели для читов". в аргоне можно более детально настроить использование ресурсов (argon2id: time + memory + threads vs bcrypt: cost), и таким образом выйти на оптимальный бизнес-логике баланс скорости/сложности, а так как читы это всё таки не гигантский SaaS, то можно сэкономить очень много ресурсов правильно настроив правила аргона при высоком потоке клиентов
 
привет, спасибо за качественную статью. хотел у тебя спросить работал ли ты с sqlx? если да, то были ли причины не использовать его в этом проекте? специфического функционала постгре (listen/notify, jsonb etc.) я не заметил, поэтому возможно если его использование в проекте не планируется, то было бы лучше заюзать sqlx с пулом от `database/sql` вместо pgxpool, учитывая насколько удобно можно подменять бдшки сохраняя один и тот же интерфейс? если была причина по которой ты выбрал pgxpool вместо sqlx то дай знать пожалуйста, никогда не пользовался этой библиотекой, поэтому не особо в курсе за её плюсы и минусы

исходя из контента статьи, я понимаю что pgx вместо sqlx был выбран не из-за скорости. я на 100% согласен что любая лишняя абстракция - трата скорости, но в целом я думаю что при обращении к бдшке мы больше переживаем за RTT, нежели за лишние микросекунды на дополнительную абстракцию, поэтому если сервер не собирается хендлить сотни тысяч RPS - было бы отлично использовать более гибкий инструментарий


ещё пара моментов по поводу архитектуры:

1. UserRepository принимает в себя Postgres, в таком случае возможно стоило бы ренеймнуть структурку в PostgresUserRepository (либо вместо Postgres в аргументах передавать унифицированный под разные БД интерфейс, к примеру sqlx.DB, и тогда уже пассить его в репозитории, и при необходимости подменить сам driverName в коннекте от sqlx на "mysql" или "postgres")
2. я думаю что в чистой архитектуре всё же лучше компоновать компоненты в app пакете, соответственно не передавать в `NewHttpHandler(postgres *db.Postgres...)` и там уже создавать UserRepo, а прямо в app.go создавать репозитории, и уже их передавать меж слоями. что-бы была одна точка входа, и одна точка для изменений

и по мелочи, мне кажется в 2025 стоит переходить на argon2id вместо bcrypt-а, особенно учитывая сферу "админ-панели для читов". в аргоне можно более детально настроить использование ресурсов (argon2id: time + memory + threads vs bcrypt: cost), и таким образом выйти на оптимальный бизнес-логике баланс скорости/сложности, а так как читы это всё таки не гигантский SaaS, то можно сэкономить очень много ресурсов правильно настроив правила аргона при высоком потоке клиентов
pgx я выбрал именно из за его производительности и из за того, что он направлен именно на постгрес, плюс с ним я работал намного больше, чем с sqlx и планирую в серии статей сильно расширить эту панель до полноценного сайта в будущем так что функционал постгреса тут пригодится. Хоть я и сказал что производительность не так важна, все же лишней не будет

Про ренейм да ты прав, так можно, но я пишу по привычному код стайлу для себя, который у нас используется на работе при разработке новых сервисов.
Репозитории не особо важно где создавать, но да учитывая архитектуру так будет лучше

Про argon2id я не слышал, почитаю информацию

Приму во внимание твое сообщение в следующих темах спасибо <3
pgx я выбрал именно из за его производительности и из за того, что он направлен именно на постгрес, плюс с ним я работал намного больше, чем с sqlx и планирую в серии статей сильно расширить эту панель до полноценного сайта в будущем так что функционал постгреса тут пригодится. Хоть я и сказал что производительность не так важна, все же лишней не будет

Про ренейм да ты прав, так можно, но я пишу по привычному код стайлу для себя, который у нас используется на работе при разработке новых сервисов.
Репозитории не особо важно где создавать, но да учитывая архитектуру так будет лучше

Про argon2id я не слышал, почитаю информацию

Приму во внимание твое сообщение в следующих темах спасибо <3
Немного уточню. Я верю в превосходство постгреса над другими sql базами данных, я немного шизик)
 
Назад
Сверху Снизу