Зачем вообще писать UI, если есть web? В некоторых случаях да, проще сделать web-приложение, тем более что фреймворки, типа Quasar во-первых имеют встроенную библиотеку ui-компонент, а во-вторых умеют собираться под разные платформы (в том числе и iOS\Android), и быть PWA, устанавливаемой на ПК\смартфон.
Но в некоторых случаях нужно либо придать более дружелюбный пользователю интерфейс к изначально консольной утилите, либо же действительно сделать утилиту для локального запуска с поддержкой GUI.
Что доступно в Go?
Во-первых это fyne. Из плюсов: кросс-компиляция, собственный рендер, независимый от платформ. Из минусов: на Windows ставить и поднимать несколько затратно. Требуется gcc с mingw. Шагов по установке немного, но компиляция готового приложения (и его отладка) - небыстрое по времени ожидания готовности сборки занятие.
Во-вторых - wails. Немного посмотрев на документацию становится понятно, что это эдакий локальный веб-сервер и браузер, показывающий в окне отрендереное web-приложение. Из коробки доступны шаблоны, например с react и vue.js. Обращение к функциям, напсанным на go осуществляется через специальный объект window.runtime.
Я попробовал fyne, и расскажу о том, как его правильно готовить.
Для Windows нужно пройти все этапы установки, описанные тут. Без этого просто не заработает. Обязательно проверьте утилитой Fyne setup состояние установки. Так как я работаю в Goland, то go sdk не виден в PATH системы. Пришлось его туда внести. Только после этого демо-примеры начали хотя бы как-то собираться.
Мне нужно было сделать небольшую хелпер-утилиту для нагрузочного тестирования сервиса. Собственно вся ее суть сводилась к отправке в очередь некоторого количества сообщений, имитируя нагрузку.
Поэтому я разбил приложение на пару файлов - отдельно ui, отдельно логика.
Что плохо в fyne - все пишется кодом. Если надо создать навороченное окно - придется писать много. В моем случае нужно было всего пару-тройку полей, прогресс бар, и кнопку для запуска.
package main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"strconv"
)
// Функиця для показа сообщения об ошибке.
// Что странно - App (инстанс приложения) передается не через указатель, а по значению. С указателем не заработало.
func showError(appInstance fyne.App, error string) {
// Для любого окна нужно создать его инстанс через инстанс приложения
window := appInstance.NewWindow("Error!")
// Все элементы UI обычно лежат в widget и создаются в коде
errorLabel := widget.NewLabel(error)
// Для их размещения на форме они добавляются в контейнеры
window.SetContent(container.NewVBox(errorLabel))
// Окно поддерживает ресайз
window.Resize(fyne.NewSize(200, 70))
// Для главного окна вызывается ShowAndRun(), а в случае посторонних окон - просто Show()
window.Show()
}
func MainWindow() {
// в main будет вызываться только эта функция, в ней создаем инстанс приложения
appInstance := app.New()
// Задаем title окна
mainWindow := appInstance.NewWindow("Token usage load test")
// Далее создаем группы контролов
// NewLabel - для надписей
// NewEntry - для полей ввода
amqpDsnLabel := widget.NewLabel("AMQP DSN")
amqpDsnInput := widget.NewEntry()
amqpDsnInput.SetPlaceHolder("AMQP DSN")
amqpDsnInput.SetText("amqp://lk:lk@127.0.0.1:5672/lk")
// Label и Entry добавляем в контейнер-таблицу из двух ячеек
amqpDsnOptions := container.New(layout.NewGridLayout(2), amqpDsnLabel, amqpDsnInput)
tokenServiceQueueNameLabel := widget.NewLabel("Queue name")
tokenServiceQueueName := widget.NewEntry()
tokenServiceQueueName.SetPlaceHolder("Queue name")
tokenServiceQueueName.SetText("requests_token_usage")
tokenServiceQueueOptions := container.New(layout.NewGridLayout(2), tokenServiceQueueNameLabel, tokenServiceQueueName)
tokenInputLabel := widget.NewLabel("Token")
tokenInput := widget.NewEntry()
tokenInput.SetPlaceHolder("Token")
tokenInput.SetText("ecc50db8-032f-4781-a429-bef2076466f4")
tokenOptions := container.New(layout.NewGridLayout(2), tokenInputLabel, tokenInput)
workersLabel := widget.NewLabel("Workers count")
workersCount := widget.NewEntry()
workersCount.SetPlaceHolder("Workers")
workersCount.SetText("1")
workerCountOptions := container.New(layout.NewGridLayout(2), workersLabel, workersCount)
countMessagesPerWorkerLabel := widget.NewLabel("Count per worker")
countMessagesPerWorker := widget.NewEntry()
countMessagesPerWorker.SetPlaceHolder("Count per worker")
countMessagesPerWorker.SetText("1")
countMessagesPerWorkerOptions := container.New(layout.NewGridLayout(2), countMessagesPerWorkerLabel, countMessagesPerWorker)
// Также понадобится прогресс бар для отслеживания прогресса
statusLabel := widget.NewLabel("Progress")
progress := widget.NewProgressBar()
progress.SetValue(0)
statusOptions := container.New(layout.NewGridLayout(2), statusLabel, progress)
var button *widget.Button
// Кнопка кроме имени принимает еще и функцию-обработчик нажатия
button = widget.NewButton("Send", func() {
// Блокируем кнопку
button.Disable()
// Проверяем введенные значения
if amqpDsnInput.Text == "" {
showError(appInstance, "AMQP DSN required!")
}
if tokenServiceQueueName.Text == "" {
showError(appInstance, "Queue name required!")
}
if workersCount.Text == "" {
showError(appInstance, "Workers count required!")
}
if countMessagesPerWorker.Text == "" {
showError(appInstance, "Count messages per worker count required!")
}
// Часть значений надо трактовать как int, поэтому тут использую atoi чтобы сконвертировать
workers, err := strconv.Atoi(workersCount.Text)
if err != nil {
showError(appInstance, "Workers count must be an integer!")
}
countMessages, err := strconv.Atoi(countMessagesPerWorker.Text)
if err != nil {
showError(appInstance, "Messages count must be an integer!")
}
// Сбрасываем прогресс бар
progress.SetValue(0)
// Запускаем воркеры
RunWorkers(amqpDsnInput.Text, tokenServiceQueueName.Text, tokenInput.Text, workers, countMessages, progress)
// После завершения включаем кнопку обратно
button.Enable()
})
// Добавялем в контейнер главного окна все субконтейнеры
mainWindow.SetContent(container.New(layout.NewVBoxLayout(),
amqpDsnOptions, tokenServiceQueueOptions, tokenOptions, workerCountOptions, countMessagesPerWorkerOptions, statusOptions, button))
mainWindow.Resize(fyne.NewSize(600, 200))
// И запускаем
mainWindow.ShowAndRun()
}
Ну и код, который запускается через RunWorker.
package main
import (
"encoding/json"
"fyne.io/fyne/v2/widget"
"github.com/rabbitmq/amqp091-go"
"log"
"sync"
"time"
)
// Функция-воркер, отправляет заданное кол-во сообщений в очередь, имитируя нагрузку
func worker(channel *amqp091.Channel, queue string, token string, count int, wg *sync.WaitGroup, progress *widget.ProgressBar, step float64) {
// Чтобы не уйти в вечный блок нужно вызвать wg.Done для декремента счетчика в WaitGroup
defer wg.Done()
// Отправляем сообщение в цикле
for range count {
data := make(map[string]any)
kwargs := make(map[string]any)
headers := make(map[string]any)
headers["x-type"] = "webhook"
kwargs["token"] = token
kwargs["amount"] = 1
kwargs["access_right"] = "atl"
data["type"] = "Token.use"
data["kwargs"] = kwargs
responseBytes, _ := json.Marshal(data)
err := channel.Publish("", queue, false, false, amqp091.Publishing{
Body: responseBytes,
Headers: headers,
})
if err != nil {
panic(err)
}
// Инкрементируем прогресс бар. Удобно, что значение бара хранится во float, поэтому можно рассчитать "вес" одной отправки, для более правильного инкремента.
progress.SetValue(progress.Value + step)
// Имитируем задержку
time.Sleep(2 * time.Second)
}
}
// Функция запускающая горутины-воркеры, создает подключение, канал, запускает несколько горутин передавая им канал в качестве аргумента
func RunWorkers(amqpDsn string, queueName string, token string, workersCount int, messagesPerWorker int, progress *widget.ProgressBar) {
connection, err := amqp091.Dial(amqpDsn)
defer connection.Close()
if err != nil {
panic(err)
}
channel, err := connection.Channel()
defer channel.Close()
if err != nil {
panic(err)
}
// WaitGroup используем для ожидания завершения всех воркеров
var wg sync.WaitGroup
step := float64(1) / float64(workersCount*messagesPerWorker)
for range workersCount {
// Инкрементируем счетчик воркеров для каждого запущенного воркера
wg.Add(1)
go worker(channel, queueName, token, messagesPerWorker, &wg, progress, step)
}
// Ждем пока счетчик не станет вновь равен нулю
wg.Wait()
}