Привет, Хабр! Недавно мне пришла задача: провести голосование среди пользователей, но без сложных и дорогостоящих решений. Когда я пришёл к выбору системы SMS-голосования, осознал, что многие решения на рынке либо слишком сложны для интеграции, либо слишком дороги для решения простых задач.
Я хотел создать что-то, что могло бы работать везде, где есть мобильная сеть. Вооружившись Golang, подключив Exolve SMS API и настроив Supabase, я приступил к работе.
Для начала нужно установить сам Go и подключить следующие библиотеки:
Supabase: прекрасный инструмент для работы с базами данных, который отлично интегрируется с Golang через GORM. В нашем случае он будет хранить данные о голосах.
Exolve SMS API: основной инструмент для работы с SMS. Ознакомиться с API можно здесь.
Gin: легковесный и быстрый фреймворк для создания веб-приложений.
GORM: одна из лучших ORM для Golang, позволяющая легко работать с базами данных.
Инициализация проекта
Создадим новый проект и инициализируем его с помощью команды go mod init:
$ mkdir sms-voting
$ cd sms-voting
$ go mod init sms-voting
Для хранения данных о голосах будем использовать Supabase.
Установим необходимые библиотеки:
$ go get -u github.com/gin-gonic/gin
$ go get -u gorm.io/gorm
$ go get -u gorm.io/driver/postgres
Регистрируемся на самом Supabase, создадим новый проект и добавим таблицу для голосов:
CREATE TABLE votes (
  id SERIAL PRIMARY KEY,
  candidate_name VARCHAR(255) NOT NULL,
  vote_count INT DEFAULT 0,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
Это позволит хранить информацию о каждом голосе и отслеживать количество голосов за каждого кандидата.
Структура проекта
Структура будет выглядеть следующим образом:
sms-voting/
├── config/
│   └── config.go
├── db/
│   └── db.go
├── sms/
│   └── sms.go
├── handlers/
│   └── sms_handler.go
├── models/
│   └── vote.go
├── main.go
└── go.mod
Конфигурация проекта
Создадим файл config/config.go для хранения конфигурационных параметров:
// config/config.go
package config
import (
    "log"
    "os"
)
type Config struct {
    DBHost        string
    DBUser        string
    DBPassword    string
    DBName        string
    ExolveAPIKey  string
    SenderNumber  string
    ServerPort    string
}
func LoadConfig() *Config {
    config := &Config{
        DBHost:       getEnv("DB_HOST", "localhost"),
        DBUser:       getEnv("DB_USER", "postgres"),
        DBPassword:   getEnv("DB_PASSWORD", "password"),
        DBName:       getEnv("DB_NAME", "votes_db"),
        ExolveAPIKey: getEnv("EXOLVE_API_KEY", ""),
        SenderNumber: getEnv("SENDER_NUMBER", ""),
        ServerPort:   getEnv("SERVER_PORT", "8080"),
    }
    if config.ExolveAPIKey == "" || config.SenderNumber == "" {
        log.Fatal("EXOLVE_API_KEY and SENDER_NUMBER must be set")
    }
    return config
}
func getEnv(key, fallback string) string {
    if value, exists := os.LookupEnv(key); exists {
        return value
    }
    return fallback
}
Не забываем установить переменные окружения перед запуском приложения:
export DB_HOST=your_db_host
export DB_USER=your_db_user
export DB_PASSWORD=your_db_password
export DB_NAME=your_db_name
export EXOLVE_API_KEY=your_exolve_api_key
export SENDER_NUMBER=your_sender_number
export SERVER_PORT=8080
Модель данных
Создадим файл models/vote.go для описания модели голосования:
// models/vote.go
package models
import (
    "time"
    "gorm.io/gorm"
)
type Vote struct {
    ID            uint      `gorm:"primaryKey" json:"id"`
    CandidateName string    `gorm:"not null" json:"candidate_name"`
    VoteCount     int       `gorm:"default:0" json:"vote_count"`
    CreatedAt     time.Time `gorm:"autoCreateTime" json:"created_at"`
}
Работа с БД
Создадим файл db/db.go для настройки подключения к базе данных:
// db/db.go
package db
import (
    "fmt"
    "log"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "sms-voting/config"
    "sms-voting/models"
)
type Database struct {
    Conn *gorm.DB
}
func NewDatabase(cfg *config.Config) *Database {
    dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=5432 sslmode=require",
        cfg.DBHost,
        cfg.DBUser,
        cfg.DBPassword,
        cfg.DBName)
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("failed to connect database:", err)
    }
    // Миграция схемы
    if err := db.AutoMigrate(&models.Vote{}); err != nil {
        log.Fatal("failed to migrate database:", err)
    }
    return &Database{Conn: db}
}
Интерфейсы
Для того, чтобы все было гибко и удобно, при том же тестировании, введем интерфейсы для работы с базой данных и отправки SMS.
Интерфейс для базы данных
Создадим файл models/vote_repository.go:
// models/vote_repository.go
package models
import (
    "errors"
    "gorm.io/gorm"
)
type VoteRepository interface {
    GetOrCreateVote(candidateName string) (*Vote, error)
    IncrementVote(vote *Vote) error
    GetAllVotes() ([]Vote, error)
}
type voteRepository struct {
    db *gorm.DB
}
func NewVoteRepository(db *gorm.DB) VoteRepository {
    return &voteRepository{db}
}
func (r *voteRepository) GetOrCreateVote(candidateName string) (*Vote, error) {
    var vote Vote
    if err := r.db.Where("candidate_name = ?", candidateName).First(&vote).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            vote = Vote{CandidateName: candidateName}
            if err := r.db.Create(&vote).Error; err != nil {
                return nil, err
            }
        } else {
            return nil, err
        }
    }
    return &vote, nil
}
func (r *voteRepository) IncrementVote(vote *Vote) error {
    vote.VoteCount += 1
    return r.db.Save(vote).Error
}
func (r *voteRepository) GetAllVotes() ([]Vote, error) {
    var votes []Vote
    if err := r.db.Find(&votes).Error; err != nil {
        return nil, err
    }
    return votes, nil
}
Интерфейс для отправки SMS
// sms/sms_service.go
package sms
import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
)
type SMSService interface {
    SendSMS(to string, message string) error
}
type exolveSMSService struct {
    apiKey       string
    senderNumber string
    apiURL       string
}
func NewExolveSMSService(apiKey, senderNumber string) SMSService {
    return &exolveSMSService{
        apiKey:       apiKey,
        senderNumber: senderNumber,
        apiURL:       "https://api.exolve.ru/sms/send",
    }
}
type SMSSendRequest struct {
    To      string `json:"to"`
    From    string `json:"from"`
    Message string `json:"message"`
}
func (s *exolveSMSService) SendSMS(to string, message string) error {
    smsSendReq := SMSSendRequest{
        To:      to,
        From:    s.senderNumber,
        Message: message,
    }
    body, err := json.Marshal(smsSendReq)
    if err != nil {
        return fmt.Errorf("error marshalling SMS request: %w", err)
    }
    req, err := http.NewRequest("POST", s.apiURL, bytes.NewBuffer(body))
    if err != nil {
        return fmt.Errorf("error creating SMS request: %w", err)
    }
    req.Header.Set("Authorization", "Bearer "+s.apiKey)
    req.Header.Set("Content-Type", "application/json")
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("error sending SMS: %w", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        respBody, _ := ioutil.ReadAll(resp.Body)
        return fmt.Errorf("failed to send SMS, status code: %d, response: %s", resp.StatusCode, string(respBody))
    }
    return nil
}
Обработчики HTTP-запросов
Создадим файл handlers/sms_handler.go для обработки входящих SMS:
// handlers/sms_handler.go
package handlers
import (
    "fmt"
    "log"
    "net/http"
    "github.com/gin-gonic/gin"
    "sms-voting/models"
    "sms-voting/sms"
)
type SMSRequest struct {
    From string `json:"from"`
    Body string `json:"body"`
}
type SMSHandler struct {
    VoteRepo   models.VoteRepository
    SMSService sms.SMSService
}
func NewSMSHandler(voteRepo models.VoteRepository, smsService sms.SMSService) *SMSHandler {
    return &SMSHandler{
        VoteRepo:   voteRepo,
        SMSService: smsService,
    }
}
func (h *SMSHandler) HandleSMS(c *gin.Context) {
    var smsReq SMSRequest
    if err := c.ShouldBindJSON(&smsReq); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    candidateName := smsReq.Body
    vote, err := h.VoteRepo.GetOrCreateVote(candidateName)
    if err != nil {
        log.Printf("Error fetching/creating vote for candidate %s: %v", candidateName, err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
        return
    }
    if err := h.VoteRepo.IncrementVote(vote); err != nil {
        log.Printf("Error incrementing vote for candidate %s: %v", candidateName, err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
        return
    }
    responseMessage := fmt.Sprintf("Ваш голос за %s принят. Спасибо за участие!", vote.CandidateName)
    if err := h.SMSService.SendSMS(smsReq.From, responseMessage); err != nil {
        log.Printf("Error sending SMS to %s: %v", smsReq.From, err)
        // Можно решить, возвращать ли ошибку пользователю или нет
    }
    c.JSON(http.StatusOK, gin.H{"message": responseMessage})
}
Основной файл приложения
Создадим файл main.go, который будет запускать сервер:
// main.go
package main
import (
    "log"
    "github.com/gin-gonic/gin"
    "sms-voting/config"
    "sms-voting/db"
    "sms-voting/handlers"
    "sms-voting/models"
    "sms-voting/sms"
)
func main() {
    // Загрузка конфигурации
    cfg := config.LoadConfig()
    // Инициализация базы данных
    database := db.NewDatabase(cfg)
    voteRepo := models.NewVoteRepository(database.Conn)
    // Инициализация SMS-сервиса
    smsService := sms.NewExolveSMSService(cfg.ExolveAPIKey, cfg.SenderNumber)
    // Инициализация обработчиков
    smsHandler := handlers.NewSMSHandler(voteRepo, smsService)
    // Настройка роутера Gin
    router := gin.Default()
    router.POST("/sms", smsHandler.HandleSMS)
    // Запуск сервера
    log.Printf("Сервер запущен на порту %s", cfg.ServerPort)
    if err := router.Run(":" + cfg.ServerPort); err != nil {
        log.Fatalf("Не удалось запустить сервер: %v", err)
    }
}
Теперь можно запустить сервер:
$ go run main.go
Пример тела запроса для тестирования:
{
    "from": "+79678880033",
    "body": "Кандидат"
}
После обработки запроса система увеличит счетчик голосов за указанного кандидата и отправит подтверждающее SMS пользователю.
Что дальше
В эту систему можно добавить дополнительные функции: отчеты, управление кампаниями, интеграцию с другими сервисами или то же масштабирование. Exolve SMS API также имеет множество возможностей для улучшения сценариев.
Делитесь своими идеями и улучшениями в комментариях. До новых встреч!
Комментарии (3)

php_freelancer
25.09.2024 12:57+3Выглядит как туториал простой апишечки на Go для новичков
Но реализация местами такая, как лучше не делать вообщеGORM конечно ультра оверхед, он скорее усложняет код здесь, не только производительность уменьшает. А AutoMigrate его это вообще кошмарный кошмар как будто не production ready изначально
Вместо Create с проверкой на ErrNotExists, можно FirstOrCreate with attrs gorm
Лучше не инкрементировать счетчик в репозитории в ссылке на модель Vote
Если 2 параллельных запроса придет c одним и тем же номером, то никакой защиты нет. Нужна блокировка на уровне строк и уникальный constraint если голос уже есть
Обновление счетчика и создание голоса в бд лучше делать в одной транзакции априори, а то голос создадим, а инкрементировать не сможем - неконсистентность. И по сути это один метод репозитория - создания голоса, а не два разных.
Зависимости с большой буквы не надо называть, если это не необходимо экспортировать реально
Интерфейсы в месте реализации, а не использования, как то слишком не туда не сюда выглядят на примере, в чем гибкость и удобство в Вашем случае?
Снова про интерфейсы. Если используется чистая архитектура и для DI куда удобнее класть интерфейсы в места использования, а у вас вообще как то непонятно вышло) Ваш код даже невозможно юнит тестами покрыть по сути, если даже опустить, что Handler зависит от репы бд напрямую...
Никогда в жизни не стоит внешние вызовы через дефолтную конфигурацию http.Client делать без таймаутов и прочих ограничений по соединениям и т.п.
Нету общепринятого gracefull shutdown, если сервис будет рестартиться во время каких то операций и открытых соединений то капут, потеря голосов, ошибки у клиентов, неконстистентность, вопросы, негодования, неудобства, смерть
Если так важно отправлять смс, то стоит предпринимать делать несколько попыток. А если не получилось, то откатывать транзакцию к бд. Иначе полная ахинея выходит.
Все эти пункты учесть можно за 10 минут без усложнения кода и реализации ровно ни на сколько, но зато работало бы как надо и выглядело более менее

michabramov Автор
25.09.2024 12:57Привет! Спасибо за развёрнутый комментарий, действительно есть над чем подумать. Начну с того, что цель статьи — продемонстрировать простой рабочий прототип системы SMS-голосования, которая должна быть понятна и полезна даже для тех, кто не сталкивался с подобными решениями. Конечно, данный код — это не эталон production-ready системы, но он вполне выполняет свою задачу в рамках туториала.
Cпасибо за критику, некоторые ваши советы действительно дельные, и мы учтем их в следующих статьях.
Будем рады вашим комментариям в будущем!
          
 
systembro
ещё бы в конце итоги голосования всем разослать и получить негодование %)