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