fyne - GUI для golang

Oct. 6, 2024

Зачем вообще писать UI, если есть web? В некоторых случаях да, проще сделать web-приложение, тем более что фреймворки, типа Quasar во-первых имеют встроенную библиотеку ui-компонент, а во-вторых умеют собираться под разные платформы (в том числе и iOS\Android), и быть PWA, устанавливаемой на ПК\смартфон.

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

GUI в Go

Что доступно в Go?

Во-первых это fyne. Из плюсов: кросс-компиляция, собственный рендер, независимый от платформ. Из минусов: на Windows ставить и поднимать несколько затратно. Требуется gcc с mingw. Шагов по установке немного, но компиляция готового приложения (и его отладка) - небыстрое по времени ожидания готовности сборки занятие.

Во-вторых - wails. Немного посмотрев на документацию становится понятно, что это эдакий локальный веб-сервер и браузер, показывающий в окне отрендереное web-приложение. Из коробки доступны шаблоны, например с react и vue.js. Обращение к функциям, напсанным на go осуществляется через специальный объект window.runtime.

Я попробовал fyne, и расскажу о том, как его правильно готовить.

Установка

Для Windows нужно пройти все этапы установки, описанные тут. Без этого просто не заработает. Обязательно проверьте утилитой Fyne setup состояние установки. Так как я работаю в Goland, то go sdk не виден в PATH системы. Пришлось его туда внести. Только после этого демо-примеры начали хотя бы как-то собираться.

Приложение

fyne application

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

Поэтому я разбил приложение на пару файлов - отдельно 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()
}