Привет, Хабр! Недавно мне пришла задача: провести голосование среди пользователей, но без сложных и дорогостоящих решений. Когда я пришёл к выбору системы 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)


  1. systembro
    25.09.2024 12:57

    ещё бы в конце итоги голосования всем разослать и получить негодование %)


  1. php_freelancer
    25.09.2024 12:57
    +3

    Выглядит как туториал простой апишечки на Go для новичков
    Но реализация местами такая, как лучше не делать вообще

    1. GORM конечно ультра оверхед, он скорее усложняет код здесь, не только производительность уменьшает. А AutoMigrate его это вообще кошмарный кошмар как будто не production ready изначально

    2. Вместо Create с проверкой на ErrNotExists, можно FirstOrCreate with attrs gorm

    3. Лучше не инкрементировать счетчик в репозитории в ссылке на модель Vote

    4. Если 2 параллельных запроса придет c одним и тем же номером, то никакой защиты нет. Нужна блокировка на уровне строк и уникальный constraint если голос уже есть

    5. Обновление счетчика и создание голоса в бд лучше делать в одной транзакции априори, а то голос создадим, а инкрементировать не сможем - неконсистентность. И по сути это один метод репозитория - создания голоса, а не два разных.

    6. Зависимости с большой буквы не надо называть, если это не необходимо экспортировать реально

    7. Интерфейсы в месте реализации, а не использования, как то слишком не туда не сюда выглядят на примере, в чем гибкость и удобство в Вашем случае?

    8. Снова про интерфейсы. Если используется чистая архитектура и для DI куда удобнее класть интерфейсы в места использования, а у вас вообще как то непонятно вышло) Ваш код даже невозможно юнит тестами покрыть по сути, если даже опустить, что Handler зависит от репы бд напрямую...

    9. Никогда в жизни не стоит внешние вызовы через дефолтную конфигурацию http.Client делать без таймаутов и прочих ограничений по соединениям и т.п.

    10. Нету общепринятого gracefull shutdown, если сервис будет рестартиться во время каких то операций и открытых соединений то капут, потеря голосов, ошибки у клиентов, неконстистентность, вопросы, негодования, неудобства, смерть

    11. Если так важно отправлять смс, то стоит предпринимать делать несколько попыток. А если не получилось, то откатывать транзакцию к бд. Иначе полная ахинея выходит.

    Все эти пункты учесть можно за 10 минут без усложнения кода и реализации ровно ни на сколько, но зато работало бы как надо и выглядело более менее


    1. michabramov Автор
      25.09.2024 12:57

      Привет! Спасибо за развёрнутый комментарий, действительно есть над чем подумать. Начну с того, что цель статьи — продемонстрировать простой рабочий прототип системы SMS-голосования, которая должна быть понятна и полезна даже для тех, кто не сталкивался с подобными решениями. Конечно, данный код — это не эталон production-ready системы, но он вполне выполняет свою задачу в рамках туториала.

      Cпасибо за критику, некоторые ваши советы действительно дельные, и мы учтем их в следующих статьях.

      Будем рады вашим комментариям в будущем!