Fullstack v2: учимся писать UI на Go

Меня зовут Илья Глухов.  Последние 7 лет я пишу на Go. Я люблю этот язык, а ещё люблю задаваться странными, на первый взгляд, вопросами. Например, как разные интересные штуки, которые мы пишем на Go взаимодействуют с пользователем? В классическом бэкенде мы при помощи RPC (Remote Procedure Call), протокола HTTP или разных очередей модифицируем поведение нашей программы. А что насчёт graphic UI? Он же из фронтенда? Или нам так только кажется? Давайте  создадим пользовательский интерфейс (UI) на Go. Выбор решений разнообразен: Gopherjs, gomobile, обёртки для Qt, GTK и много чего ещё. Но если мы хотим добиться кросс-платформенной совместимости для браузеров, мобильных устройств и десктопов, нам нужен универсальный UI. Давайте на практическом примере разберём как создать его на Go.

11579225448fb52b07751511fe19c4e4.jpeg

Зачем нужен UI?

Каждая программа может реагировать на пользовательский ввод по-разному. В зависимости от типа приложения, это могут быть:

  • Параметры командной строки: если вы пишете утилиту для оболочки.

  • Переменные окружения: если вы разрабатываете 12-факторное приложение.

  • RPC, HTTP или очереди сообщений: если это бэкэнд.

Но когда дело доходит до UI, многим из нас приходит на ум лишь мир фронтенда…

Изначально программы появились как «тонкие клиенты» к мейнфреймам. За ними пришли персональные компьютеры. Там и логика, и бизнес-логика, и слой представления были реализованы, скажем так, в одном монолите. А когда появился интернет и концепция гипертекста,   разработчикам пришла в голову светлая идея — использовать «толстый клиент», на котором будет исполняться представление программы. Потому что интернет тогда был медленный, процессоры — «дохленькие», а браузеры — «молодые», и другого варианта, в общем-то, не было. Сначала концепция Document Object Model (DOM) работала только на исполнение представления программы в браузере клиента, но потом её начали использовать для улучшения сайтов. Появились снежинки, музыка и прочее нестандартное использование DOM, которое лежит в основе гипертекста. Потребовались специальные усилия, чтобы воплотить это в браузере клиента. Так фронтенд стал отдельной специальностью.

Потом появились мобильные устройства со своей операционкой, физическим представлением, экраном, по которому надо «тапать» — и всё усложнилось ещё сильнее.

Достоинства и недостатки

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

С другой, мы получаем недостатки:

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

  • Два центра ответственности — фронтендеры валят на бэкендеров, а бэкендеры — на них обратно. 

  • Отсутствие единой концепции разработки — фронтенд преимущественно про DOM, а бэкенд больше про абстракции и данные.

  • Два и более языка — свой pipeline, свои различия и хаос.

Учитывая всё перечисленное, напрашивается логичное решение —  это универсал.

832e63e5b6c72ed169c83df4d019a702.jpeg

И это не большая семейная машина из рекламы, а разработчики, которые одинаково хорошо владеют как технологиями фронтенда, так и бэкенда.

Их назвали fullstack.

Fullstack v1

Поскольку на фронтенде хорошо проявил себя JavaScript, его решили распространить на бэкенд. Так в 2009 появился NodeJS и первый fullstack.  Сейчас происходит нечто подобное. Kotlin стал популярным в официальной разработке для Android, и теперь отвоевывает долю у Java на бэке. А благодаря мультиплатформе начал компилироваться на разные на устройства. То есть у нас снова зарождается fullstack. И крупные корпорации не остались в стороне этой истории.

Кроссплатформенные решения

Компания Microsoft купила Xamarin и включила его в платформу .NET, чтобы дать возможность разработки на нескольких языках всем известным системам.

Другая корпорация задалась вопросом, хорошо ли фреймворк React работает с Web API и будет ли работать с native API. Так появился React Native.

Google тоже сделал универсальное решение — фреймворк Flatter.

Но вернёмся к Go, и посмотрим, что появилось для этого языка.

Решения использующие Go

Svelte (JS) + Go templating. Svelte — фреймворк №3 в списке самых популярных JavaScript фреймворков за 2023 год. Использует Go templating для динамического пересоздания страниц.

HTMX (JS) + Go (Templ). Фреймворк №2 из того же списка — HTMX. Это попытка вдохнуть новую жизнь, сделать реактивным и добавить функций в старый добрый HTML. HTMX работает в связке с Go (Templ).

Go file server. Это как template, только с возможностью встраивать его прямо в Go-код и использовать внутри Go-файлов.

http.FileServer(http.Dir(directoryPath))

ff1bec31a837c2ce0a1e5161b53034f2.jpeg

В конце концов, мы можем использовать просто Go. Сделать файл-сервер из коробки, накидать туда html-файликов, и у нас будет какой-то фронтенд. Правда, он будет выглядеть родом из 90-х. А вот чтобы сделать что-то более современное, понадобится Go кросс-компиляция и GUI-фреймворки. Перейдём к ним.

Go кросс-компиляция

У нас есть разные таргеты:

В версии 1.15 есть wasm, а с версии 1.21 даже доступ к WASI через wasm.

Кроме того, есть возможность билдить в нативный код, то есть таргет для iOS и для Android.

  • binary (desktop)

Возможность билдить в Darwin, Windows и Linux.

GUI-фреймворки

Их достаточно много. Они перечислены в отличном рубрикаторе. И там можно всё подробно почитать и посмотреть. А для нашей истории важно, с какого фреймворка всё началось и какими продолжалось:  

  • gomobile — уже почти не поддерживается, но именно он дал толчок мобильной разработке под Go.

  • qt — Qt binding for Go (support for Windows / macOS / Linux / Android / iOS / Sailfish OS / Raspberry Pi) — тоже древний фреймворк, в основном использовался в «плюсах». На нём написаны многие программы. Например, Telegram, KDE и VLC. А байндинг qt для Go позволяет разрабатывать практически для любых  систем.

  • go-gtk  GTK aka GIMP Toolkit — тоже из старых. С её помощью сделан Cinnamon и куча  «гномовских» утилит.

  • go-sciter HTML CSS for desktops — для написания десктоп приложений, если вы очень хороши в HTML и CSS.

  • fyne — современный кроссплатформенный фреймворк для Linux, macOS, Windows, BSD, iOS and Android. Базируется на Material Design. Есть дополнительная логика для создания приложений. Очень дружественное и активное комьюнити с каналами в Slack и Discord, FyneConf, книги, видео.

  • gio — более «олдскульный» кроссплатформенный фреймворк: Linux, macOS, Windows, Android, iOS, FreeBSD, OpenBSD and WebAssembly. Позиционируется как true open-source. В частности, у них есть зеркало на GitHub, но они базируются именно на sourcehut и используют для обсуждения maillist. Из отличительных черт — простая структура и возможность контрибьютить даже тем, кто забанен на GitHub.

Я для себя выбрал GioUI. Мне понравился их true open source (они дают возможность контрибьютить даже тем, кто забанен на GitHub) и набор функций. У него есть некоторая дополнительная логика, но в основном это всё-таки библиотека. Благодаря этому у него более простая, на мой взгляд, структура.

GioUI использует Immediate graphic mode. И этот момент, наверное, надо раскрыть.

Практически любая графика, использует два подхода:

  1. Retained Mode (примеры: JavaScript, Document Object Model, PDF-файлы)

b656687c3601b616f6465b6233549cc9.png

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

  1. Immediate mode, когда в слое приложения мы прямо рисуем сцену.

5a55f144ac31decc67818f925b228e8b.jpeg

Библиотека использует нативный код для соответствующего железа, где запущен наш код.

Gio использует как раз этот графический подход. И думаю, теории уже хватит, пора переходить к практике. А именно, к созданию приложений.

Создание приложения

Предположим мы решили делать Trader AI — рабочее место трейдера для мобил, веба и десктопа. Приложение, которое будет давать советы с помощью искусственного интеллекта.

Постановка задачи

В этом приложении будет:

  • просмотр котировок,

  • редактирование списка котировок,

  • создание лимитированных ордеров, то есть отправка их на биржу, исполнение,

  • контроль состояния портфеля,

  • вопросы AI-ассистенту.

Инструменты

Мы будем использовать Gio с тремя слоями:

  1. Стандартные элементы — виджеты,

  2. Графические элементы, если вам нужна специальная графика — canvas и draw.

  3. native API (web API) — дополнительные возможности, чтобы дотянуться до проигрывания видео, файловых операций и всего того, что требует доступа к системе.

Виджет

Будем делать виджет. То есть абстракцию, которая будет описывать элемент графической сцены. Его можно создать, нарисовать. Он может обрабатывать пользовательский ввод.

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

Также виджет должен иметь Layout.

В терминах GioUI виджет — это функция.

func (gtx layout.Context) layout.Dimensions

Если вы хотите, чтобы что-то стало виджетом, вы должны описать это как функцию с контрактом input-параметр, графическим контекстом, и output-параметром, то есть положением на графической сцене.

Material Design

Gio использует Material Design — это open-source дизайн-система, разработанная Google. Она нужна, чтобы не погружаться в детали графического представления. Например, не думать как сделать кнопку.  Спасибо Google, на это можно не тратить время. Большинство приложений на Android используют Material Design.

Эта система описывает следующие элементы:

  • Buttons,

  • Icon buttons,

  • Radio buttons,

  • Label,

  • Switch,

  • Progress bar.

Это только основные элементы, чтобы было понятно, о чём идёт речь.

Элементы

У нас в приложении будет:

  • Кнопка — базовый способ взаимодействия с пользователем.

  • Поле ввода, например, количество, которое хотим продать, тикер, который хотим добавить. Это текст.

  • Сетка — некое динамическое представление, наполненное тикерами, куда мы можем добавлять либо убирать тикеры.

  • Таблица для описания ценностей, которые у нас есть, и изменений, которые с ними происходят.

  • Поле текстового вывода или логер, в котором будет происходить общение с AI-ассистентом.

Flex

Это горизонтальная и вертикальная группировка графических элементов. Элемент flex может быть либо rigid, то есть фиксированный, либо flexible, то есть подстраиваемый под соседей.

136aff62d6ad4447cf9f1a2efc6bba88.jpeg

Перейдём непосредственно к коду.

Базовая конструкция

package main

import "gioui.org/app"

func main() {
   go func() {
       w := app.NewWindow() //создается новое окно графической сцены//

       for {
           w.NextEvent() //запускается бесконечный цикл для прослушивания событий этого окна//
       }
}()

    app.Main() //управление передается системе//
}

Это Hello, World — программа, которая выведет на экран пустой экран с кнопками закрытия-открытия и названием.

2f2c6d1f4cd68df2d2b9ee00f3455f88.jpeg

Система останавливается, ожидая команды выхода, либо какого-то события, которое приведет к окончанию исполнения.

Дизайн без дизайна

Поскольку мы занимаемся фронтом, нам нужен дизайн. Но я — бэкендер, поэтому сделал простой дизайн, даже Figma не понадобилась:

d18759d04b443a38997d16b7be3a77cf.jpeg

У нас будет пять частей приложения: управление ордерами, управление тикерами, тикеры, портфель и AI-советчик.

Элементы

Чтобы отрисовать кнопку, воспользуемся package Material Designer.

Кнопка

Возьмём в нём функцию button и передадим в неё графическую тему, ресивер: любой объект, который будет держать состояние этой самой кнопки. Также передаём название кнопки и возвращаем положение Layout.

return material.Button (th, orderButton, «make order»).Layout (gtx)

В нашем приложении это будет выглядеть гораздо страшнее.

layout.Rigid(
func(gtx layout.Context) layout.Dimensions {
          return in.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
          return material.Button(th, orderButton,
"make order").Layout(gtx)
          })
}),

Это будет обрамлено в элемент Rigid Flex. Для красоты я добавил чёрную границу.

1f5f39829432a5dcaafc0c4d75262e0e.jpeg

Радиокнопка

Примерно та же логика. Мы опять используем Rigid, внутри package Material Design, функция radio button, тема, ресивер и название.

layout.Rigid(material.RadioButton(th, bidaskGroup, "bid", "bid").Layout),

layout.Rigid(material.RadioButton(th, bidaskGroup, "ask", "ask").Layout),

Важный момент: ресивер одинаковый у обоих позиций. Потому что ресивер — это enum, то есть перечисление, которое содержит возможное значение из тех, что мы ожидаем. Читая это значение, мы получаем какое состояние сейчас выбрано.

Радиокнопка в нашем приложении будет выглядеть вот так.

return in.Layout(gtx,
   func(gtx layout.Context) layout.Dimensions {
            return layout.Flex{}.Layout(gtx,
   layout.Rigid(material.RadioButton(th, bidaskGroup, "bid", "bid").Layout),
   layout.Rigid(material.RadioButton(th, bidaskGroup, "ask", "ask").Layout),
   )
}

Для красоты упаковки это опять завернуто во flex.

73be33dff5a2ea64c3cfa044fe5f1119.jpeg

Поле ввода

Самая простая штука — поле ввода. В Editor передаём тему, ресивер и название.

      material.Editor(th, &amountInput, "amount")

В приложении выглядят страшнее.

layout.Rigid(func(gtx layout.Context) layout.Dimensions {
   e := material.Editor(th, &amountInput, "amount")
    e.Font.Style = font.Italic
    border := widget.Border{
     Color: color.NRGBA{A: 0xff},
     CornerRadius: unit.Dp(8),
     Width: unit.Dp(2)}
   return border.Layout(gtx, func(gtx layout.Context)
   layout.Dimensions {
      return layout.UniformInset(unit.Dp(8)).Layout(gtx, e.Layout)
…

Честно говоря, я сделал, чтобы выглядело аккуратнее. Задал руками фон, границу, шрифт, и опять всё упаковал в Rigid.

05a1a121280056735240fc4e180352d9.jpeg

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

Main

Это больше не Hello World, а наша функция.

func main() {
   go update()

   go func() {
       w := app.NewWindow(app.Size(unit.Dp(700), unit.Dp(1200)),
           app.Title("easy trader"))
       if err := handle(w); err != nil {
           log.Fatal(err)
       }

       os.Exit(0)
   }()

   app.Main()
}

Внешне довольно похоже, но есть важное отличие — в начале Main запускается go update. Это хендлер, который взаимодействует с внешним миром. В нашем случае это биржа. Там какой-то API, он как-то слушает эту биржу, либо биржа сама что-то шлёт.

Дальше уже известное нам создание нового окна с нужными размерами. Мы запускаем хендлер, то есть процедуру, которая будет хендлить состояние нашего окна.

И останавливаемся на app.Main в ожидании событий окончания.

Теперь смотрим, что происходит внутри event handler.

go func() {
    for {
        ev := w.NextEvent()
        events <- ev
        <-latch
        if _, ok := ev.(app.DestroyEvent); ok {
            return
        }

     }
}()

Мы читаем сообщение и кладём его в канал сообщений, который читается потом обработчикам. Дальше мы останавливаемся на защёлке (latch) в ожидании обработки.

Это происходит потому, что графическая сцена является одновременно и источником событий, и представлением, которое мы отрисовываем. У нас возникает livelock, то есть конфликт интересов двух сторон. Поэтому мы дожидаемся обработки, и только потом пытаемся слушать, что у нас происходит.

Важный момент: в графическом хендлере мы отрисовываем графическую сцену.

У нас есть два способа отрисовки:

  1. Если произошло какое-то событие бизнес-логики, не имеющее отношения к пользователю, например, изменились котировки, то мы заново рисуем сцену.

  2. Если к нам прилетело любое внешнее событие, например, апдейт тикера (естественно, кейсов может быть больше), мы обновляем данные существующей сцены и перерисовываем существующую сцену.

for {
         select {
         case e := <-events:
            // обрабатываем событие интерфейса
            e.Frame(gtx.Ops)
         case ut := <-updatesTicker:
           // обновляем данные и перерисовываем
             w.Invalidate()
         }
  }

Если нам пришло графическое сообщение, то обработка происходит по свитчу с type.

select {
   case e := <-events:
       switch e := e.(type) {
       case app.DestroyEvent:
           // закрываемся
       case app.FrameEvent:
          // конструируем страницу
       }
}

Если сообщение нам интересно, мы конструируем страницу, если это сообщение закрытия, мы закрываемся с graceful shutdown.

Если нам прилетело полезное сообщение, мы конструируем новый графический контекст, который содержит всю информацию о том, что происходит в графике, и вызываем функцию «отрисовать главный экран».

case app.FrameEvent:
     gtx := app.NewContext(&ops, e)
     drawTopScreen(gtx, th)
     e.Frame(gtx.Ops)

Он у нас один, поэтому главный. Мы передаём ему графический контекст и тему Material Design, которую создали. Дальше у этого события вызываем функцию Frame для того, чтобы перерисовать сцену.

В случае, если нам прилетел сигнал на обновление данных, находим тикер по имени в нашем приложении и обновляем значение цены и изменение цены. После этого делаем Invalidate.

case ut := <-updatesTicker:
     idx := slices.IndexFunc(myTickers,
     func(t ticker) bool {
         return ut.name == t.name
     })
     myTickers[idx].value = ut.value
     myTickers[idx].diff = ut.diff
     w.Invalidate()

Это практически вся логика, которая находится внутри приложения.

Деплой

Для начала посмотрим как происходит деплой для десктопа.

Десктоп

Это обычный go run или go build. Для Windows это может быть кросс-компиляция с Linux на Windows, с Windows на Linux. Единственное, как обычно, с iOS так просто всё это не произойдёт. На iOS должен быть установлен Xcode для того, чтобы это скомпилировать. Если нет Мака, в зависимости от того, где вы разместили код, вам поможет:

  • Docker;

  • GithubActions;

  • GitlabRunner.

Я использовал GitHub, в actions создал pipeline. Благодаря этой сборке, хотя у меня и нет Мака, я могу убедиться, что оно собирается.

Под Linux всё наше приложение — это несколько файлов. Делаем go run и всё запускается.

50e446ea5b5e121d0d36bcbd97d81954.png

У нас портфель внизу и всякие смешные монеты, типа доджа. Мы имеем watch list и наблюдаем за какими-то тикерами. Вверху кнопки, и они не активны, потому что у нас пока недостаточно состояний для того, чтобы их активизировать. Чтобы кнопки активизировать, мы должны, например, выбрать тикер и продать, например, USDT.

Ещё у нас есть AI-ассистент. Мы можем его о чём-то спросить и получить ответ.

Мобилки

Для Android нам понадобится APK. Это архив с манифестом, бинарным кодом и точкой входа для Java машины. Поэтому нам нужен установленный Android SDK и NDK bundle.

Мы можем вызвать gogio, это специальная утилита от GioUI с таргетом Android, который запустит сборку бинарного кода в APK файл с именем trader. В результате появится два новых файла: файл подписи для размещения где-нибудь и файл apk.

Давайте на телефон на Android установим наш APK.

53341ed1939a8065d6f4047b598f1f3b.jpg

Появилось наше приложение Trader, попробуем запустить. Мы видим ровно тот же интерфейс, который мы сгенерировали из того же самого кода, никаких дополнительных действий. Но если вводить количество, то появляется Google клавиатура.

Тут также можно добавить или убрать тикер.

На iOS, как я сказал, нужны дополнительные шаги, связанные в основном с подписью разработчика.

Web 

Чтобы собрать наше приложение, берём gogio с таргетом js.

df6538ffdc204f1974440e86a74ba3c5.jpg

В результате получаем файл-сервер, который мы запустим при помощи вспомогательной утилиты goexec. Она позволяет запускать oneliner-ы без сборки. То есть на своём сервере вы просто можете сделать goexec ListenAndServe и запустится сервер.

В результате сборки у нас появился новый каталог server с тремя файлами:

  1. main.wasm — это просто wasm-код нашего приложения.

  2. wasm.js — точка входа, просто транспорт, то есть JavaScript, который берёт wasm и кладёт в машину браузера.

  3. index.html, который просто запускает wasm.js, если обратиться к нему.

Если запустить наш сервер с помощью goexec и перейти на localhost:8080, то наше приложение будет запущено в браузере.

Заключение

Хочу обратить ваше внимание, что мы использовали исключительно Go-код. Он довольно небольшой, поэтому получилось собрать всё буквально в несколько кликов для мобилок, веба и десктопа. И, мне кажется, что мы добились нашей цели — сделать графический UI используя исключительно Go.

© Habrahabr.ru