Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +50 XP

Мониторинг и метрики

мониторинг connect — это интерсептор, который измеряет каждый вызов: счётчики с процедурой и кодом в метках, гистограммы latency, датчик текущих вызовов и /metrics на том же mux, где живёт сам сервис. Начнём с официального otelconnect, соберём собственный слой Prometheus для полного контроля и разберём карточность — главную цену метрик.

Содержание урока · 11 разделов

У вашего notes-сервиса есть TLS, авторизация, лимиты и сжатие, но всё это работает вслепую: ни вы, ни дежурный инженер не знаете, сколько вызовов приходит, сколько из них падает и что происходит с длительностью. мониторинг connect — это слой наблюдаемости, который отвечает на три вопроса в реальном времени: какой поток запросов, какая доля ошибок, какая задержка. В седьмом уроке мы заложили фундамент: интерсептор знает процедуру через req.Spec().Procedure, видит и успех, и провал, и обёрнут во всю цепочку WithInterceptors — идеальное место для измерений. В тринадцатом уроке мы обещали, что auth-события лягут в счётчики, — в этом уроке обещание исполняется: каждое отклонение по токену станет значением метки code. В четырнадцатом мы оставили второй мостик: сжатие нельзя настраивать вслепую, эффект надо увидеть на живом трафике — здесь появятся и размеры сообщений. План такой: сначала официальная машина — пакет connectrpc.com/otelconnect, который канон из седьмого урока советовал начать с него; затем собственный интерсептор с Prometheus для полного контроля над именами, метками и бакетами; затем стриминги, размеры, карточность и сборка /metrics на общем mux. Ключевые слова connect metrics и RED-метод приведут вас в доки Prometheus — сверяйтесь, когда пойдёте дальше урока.

Что видно из интерсептора: RAW-метод на RPC#

Мониторинг connect начинается с честного ответа на вопрос «что вообще измерять». Классический ответ индустрии — метод RED: Rate, Errors, Duration — частота вызовов, доля ошибок, длительность. Для RPC это ровно то, что видно из интерсептора, и ничего доустанавливать не нужно. Rate: интерсептор выполняется на каждом вызове, значит инкремент счётчика в нём — это точная частота по каждой процедуре. Errors: после вызова next у интерсептора в руках error — и Connect даёт типизированный код через connect.Error, шестнадцать кодов из шестого урока; код в метке — и у вас на доске видно, что растёт именно unauthenticated, а не internal. Duration: time.Now до и после next — длительность всего вызова целиком, вместе с хендлером и всеми внутренними слоями. Обратите внимание на масштаб: интерсептор один, а метрик полный набор на все методы всех сервисов — то же свойство, что делало его идеальным местом для логов в седьмом уроке. Чего из интерсептора не видно: содержимое сообщений (и хорошо — бизнес-данные в метриках не место), состояние очередей и GC (это метрики процесса, их собирает клиент Prometheus автоматически), поведение конкретной реплики за балансировщиком (это решают метки инстанса и сервис-дискавери). Граница простая: всё, что про вызов RPC, — в интерсептор; всё, что про процесс и инфраструктуру, — рядом, в том же реестре, но из других источников.

Официальный слой: otelconnect одной опцией

GO
import "connectrpc.com/otelconnect"

path, handler := notesv1connect.NewNoteServiceHandler(
        &notesServer{},
        connect.WithInterceptors(
                otelconnect.NewInterceptor(
                        otelconnect.WithTracerProvider(tp),
                        otelconnect.WithMeterProvider(mp),
                ),
        ),
)
mux.Handle(path, handler)

Канон из седьмого урока в действии: официальный интерсептор OpenTelemetry расставляет на каждый вызов трейсы и метрики с процедурой в атрибутах — без единой собственной строки. TracerProvider и MeterProvider приходят из вашей OTel-сборки; без них интерсептор работает с глобальными провайдерами. Это старт по умолчанию, а дальше — собственный слой, если нужен полный контроль.

Свой слой Prometheus: счётчики, гистограммы, датчики#

Зачем самописный слой, если есть otelconnect? Две рабочие причины. Первая — экспорт: Prometheus скрейпит HTTP-эндпоинт /metrics и ждёт формат Prometheus; если ваша инфраструктура уже стоит на этом стеке, метрики connect в его реестре — самое короткое расстояние между интерсептором и дашбордом. Вторая — контроль: свои имена, свои метки, свои бакеты гистограмм; библиотечный слой хорош дефолтами, но когда дежурный хочет видеть notes_rpc_duration_seconds с конкретными бакетами, а не абстрактные histogram_seconds, побеждает собственный код. Три типа метрик из client_golang закрывают RAW-метод целиком. Counter — монотонный счётчик: количество вызовов, только растёт; парный с ним rate вычислит Prometheus сам. Histogram — распределение длительности по бакетам: из него Prometheus считает перцентили p95 и p99, в отличие от среднего, которое хвост прячет. Gauge — текущее значение туда-сюда: сколько вызовов исполняется прямо сейчас, полезно для насыщения. Каждая метрика с метками (label): для счётчика вызовов — procedure и code, для гистограммы — procedure. Правило именования в стиле Prometheus: префикс приложения, объект, единица измерения — notes_rpc_requests_total, notes_rpc_duration_seconds; единица длительности — секунды, всегда секунды, Prometheus любит базовые единицы. Регистрация через prometheus.MustRegister в init — и все три метрики живут в дефолтном реестре, откуда их заберёт promhttp.

Декларация метрик notes-сервиса

GO
import (
        "github.com/prometheus/client_golang/prometheus"
        "github.com/prometheus/client_golang/prometheus/promhttp"
)

var rpcRequests = prometheus.NewCounterVec(
        prometheus.CounterOpts{
                Name: "notes_rpc_requests_total",
                Help: "RPC calls by procedure and outcome code",
        },
        []string{"procedure", "code"},
)

var rpcDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
                Name:    "notes_rpc_duration_seconds",
                Help:    "Unary call duration",
                Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5},
        },
        []string{"procedure"},
)

var rpcInFlight = prometheus.NewGauge(
        prometheus.GaugeOpts{
                Name: "notes_rpc_in_flight",
                Help: "Calls being served right now",
        },
)

func init() {
        prometheus.MustRegister(rpcRequests, rpcDuration, rpcInFlight)
}

Три метрики RAW-метода: счётчик с двумя метками (procedure, code), гистограмма с одной (procedure) и датчик без меток. Бакеты от пяти миллисекунд до двух с половиной секунд — стартовая сетка для сервисов с ответами в десятки миллисекунд; её стоит пересобрать под свой профиль, когда накопится статистика. MustRegister кладёт всё в дефолтный реестр.

Почему у счётчика вызовов метки procedure и code, но не user_id?

Интерсептор метрик: unary от и до#

Теперь сердце урока — интерсептор, который превращает декларации из прошлого блока в измерения. Скелет тот же, что у логирующего интерсептора из седьмого урока: до next — подготовка, после — фиксация. До: rpcInFlight.Inc() и defer rpcInFlight.Dec() — датчик текущих вызовов корректно работает даже при панике благодаря defer; time.Now() — стартовая точка. После: ответ или ошибка. Если err равен nil — код CodeOK; иначе приведение к connect.Error через errors.As — паттерн, который вы знаете с шестого урока, — и connectErr.Code() даёт типизированный код. Здесь же исполняется обещание тринадцатого урока: запрос с битым токеном, отклонённый auth-интерсептором, вернётся сюда как ошибка с кодом CodeUnauthenticated — тем самым unauthenticated из шестого урока, HTTP 401 — и счётчик запишет пару (процедура, code_unauthenticated). Метки собираются WithLabelValues: первая — req.Spec().Procedure, вторая — code.String(). Затем два наблюдения: rpcRequests...Inc() и rpcDuration...Observe(time.Since(start).Seconds()). Две детали, которые легко упустить. Первая: Observe и Inc идут после next в любом случае — через явную последовательность или через единый defer; если поставить фиксацию до next, вы будете измерять только момент запуска. Вторая: duration считается от входа в интерсептор до выхода из next — то есть включает все внутренние слои цепочки, auth и хендлер; это желаемое поведение, но помните про него, когда будете сравнивать цифры с логами из седьмого урока.

WrapUnary с кодом из errors.As

GO
type metricsInterceptor struct{}

func (m *metricsInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
        return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
                rpcInFlight.Inc()
                defer rpcInFlight.Dec()

                start := time.Now()
                res, err := next(ctx, req)

                code := connect.CodeOK
                if err != nil {
                        var connectErr *connect.Error
                        if errors.As(err, &connectErr) {
                                code = connectErr.Code()
                        } else {
                                code = connect.CodeUnknown
                        }
                }

                rpcRequests.WithLabelValues(req.Spec().Procedure, code.String()).Inc()
                rpcDuration.WithLabelValues(req.Spec().Procedure).
                        Observe(time.Since(start).Seconds())
                return res, err
        }
}

Полный Raw-цикл: датчик, таймер, вызов, извлечение кода через errors.As, два наблюдения. Отклонение от auth-интерсептора тринадцатого урока приедет сюда ошибкой — счётчик получит метку code=unauthenticated, и на дашборде будет видно и число попыток обхода, и всплески отказов. Ошибки без connect.Error (паника, транспорт) — CodeUnknown.

Какая метка ляжет в счётчик?

Прочитайте код и выберите, что он выведет:

$ curl -X POST http://localhost:8080/notes.v1.NoteService/GetNote \
    -H "Content-Type: application/json" -d '{"id": "1"}'

// Хендлер собран так:
connect.WithInterceptors(&metricsInterceptor{}, &authInterceptor{})

Стримы: тот же код + подсчёт сообщений#

Стриминговые вызовы из восьмого урока измеряются тем же интерсептором с теми же правилами — с одним расширением. WrapStreamingHandler получает conn вместо req: стартовая точка — conn.Spec().Procedure, заголовков — conn.RequestHeader(), итоговый код — из той же ошибки, которую метод возвращает через return. Датчик и гистограмма ложатся без изменений: стрим длится дольше unary, и это увидите в бакетах. А вот для частоты сообщений нужна обёртка: интерфейс connect.StreamingHandlerConn даёт методы Receive и Send, и если обернуть соединение структурой, встраивающей исходный conn, можно инкрементировать счётчики на каждом успешном сообщении — receive для входящих, send для исходящих. Это принцип встраивания Go: поля и методы исходного conn достаются бесплатно, переопределяются только два. Счётчик сообщений — отдельная метрика с метками procedure и direction, потому что стрим асимметричен: серверный поток может слать тысячи сообщений, не получая ни одного. Помните урок из восьмого про отмену контекста: Receive и Send возвращают ошибки — счётчик инкрементируется только на err == nil, иначе вы посчитаете сообщения, которых не было. Три метода интерфейса Interceptor обязаны существовать все — канон седьмого урока: WrapStreamingClient у метрик остаётся pass-through, если вы не меряете исходящие вызовы.

Обёртка conn для счётчика сообщений

GO
var rpcStreamMessages = prometheus.NewCounterVec(
        prometheus.CounterOpts{
                Name: "notes_rpc_stream_messages_total",
                Help: "Messages in streams by procedure and direction",
        },
        []string{"procedure", "direction"},
)

type countedConn struct {
        connect.StreamingHandlerConn
        procedure string
}

func (c *countedConn) Receive(msg any) error {
        err := c.StreamingHandlerConn.Receive(msg)
        if err == nil {
                rpcStreamMessages.WithLabelValues(c.procedure, "receive").Inc()
        }
        return err
}

func (c *countedConn) Send(msg any) error {
        err := c.StreamingHandlerConn.Send(msg)
        if err == nil {
                rpcStreamMessages.WithLabelValues(c.procedure, "send").Inc()
        }
        return err
}

func (m *metricsInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
        return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
                rpcInFlight.Inc()
                defer rpcInFlight.Dec()
                conn = &countedConn{StreamingHandlerConn: conn, procedure: conn.Spec().Procedure}
                start := time.Now()
                err := next(ctx, conn)
                // код ошибки — тот же errors.As, что в WrapUnary
                rpcDuration.WithLabelValues(conn.Spec().Procedure).Observe(time.Since(start).Seconds())
                return err
        }
}

Встраивание StreamingHandlerConn: переопределены Receive и Send, всё остальное (Spec, RequestHeader, ResponseHeader) пробрасывается исходному conn. Счётчик сообщений с меткой direction разделяет входящий и исходящий потоки. Длительность стрима считается той же гистограммой, что и unary, — на дашборде стримы и unary лягут рядом, и разница жанров станет видна.

Сколько сообщений посчитает счётчик?

Прочитайте код и выберите, что он выведет:

// Bidi-стрим: клиент шлёт 3 сообщения, хендлер эхо-отвечает на каждое.
// На третьем Receive транспорт вернул ошибку (клиент отключился):
// received: err != nil на третьем вызове
// send: 2 успешных ответа ушли до разрыва
err := next(ctx, conn) // вернул connect.Error с CodeCanceled

Зачем гистограмма с бакетами, если можно усреднять длительность?

Размеры сообщений: исполнение обещания четырнадцатого урока#

Четырнадцатый урок оставил прямой мостик: чтобы решить, окупается ли сжатие, нужны размеры сообщений на живом трафике — теперь они у вас есть, и вот как их снимать. Для unary-вызовов в Connect-протоколе размер входящего сообщения доступен как Content-Length в заголовках: req.Header().Get("Content-Length") — парсите строку в число и подаёте в гистограмму notes_rpc_request_bytes с бакетами в байтах и КиБ. Честные оговорки: заголовок даёт размер тела запроса — сжатие из четырнадцатого урока меняет эту цифру (замер показывает сжатый размер, реальный объём данных больше, и именно это вам нужно для решения о пороге); gRPC-жанры кадрируют сообщения собственной рамкой и Content-Length означает размер всего кадра, а не сообщения; у ответа соответствующий заголовок появляется после сериализации. Практический рецепт: снимайте распределение размеров на входе, сравнивайте с порогом WithCompressMinBytes из четырнадцатого урока — если медиана ниже порога, а хвост редкий, сжатие почти не работает и его место под вопросом; если масса трафика выше порога, gzip окупается и латентность от распаковки не заметна. Вторая связь — с лимитами тринадцатого: гистограмма размеров покажет, насколько далеко реальные payload-ы от WithReadMaxBytes; если p99 вплотную к лимиту, любой рост трафика начнёт превращаться в ресурсные отказы, и лимит пора пересматривать. Так связываются три урока: размеры из этого урока, порог из четырнадцатого, потолок из тринадцатого.

Карточность: главная цена и дисциплина меток#

Дисциплина меток — то, что отличает метрики на годы от метрик на неделю. Правило простое: метка годится, только если множество её значений ограничено дизайном API. procedure ограничено числом методов ваших proto-сервисов — десятки; code ограничено шестнадцатью кодами; direction — двумя. А вот список кандидатов, которые кажутся естественными и разрушают реестр: user_id и идентификаторы сущностей (тысячи-миллионы), URL с параметрами (каждый id — новая серия), IP-адреса (DHCP и мобильные сети делают их бесконечными), версии клиентов (растут с каждым релизом неограниченно). Цена ошибки кумулятивна: серии создаются и живут вечно, память растёт на каждом инстансе, скрейп замедляется, а запросы к реестру деградируют. Если диагностическая потребность в детализации до пользователя реальна — это вопрос логов с полями и трейсов, где каждая запись самодостаточна и дешева, а не временной серии на каждого клиента. Второй аспект дисциплины — консистентность: одна и та же метка должна называться одинаково во всех метриках (procedure, не proc и не method), иначе дашборды не склеятся. Третий — регистрация: все метрики сервиса регистрируются один раз в init или в конструкторе; динамическая регистрация во время работы — путь к гонкам и утечкам. Эти три правила стоят дешевле всего в начале и дороже всего в конце.

Сборка сервера: /metrics на общем mux

GO
mux := http.NewServeMux()
mux.Handle("GET /metrics", promhttp.Handler())
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
})

path, handler := notesv1connect.NewNoteServiceHandler(
        &notesServer{},
        connect.WithInterceptors(&metricsInterceptor{}, &authInterceptor{}),
        connect.WithReadMaxBytes(1<<20),
)
mux.Handle(path, handler)

log.Fatal(http.ListenAndServe(":8080", mux))

/metrics живёт на том же mux, что и RPC-хендлер, — отдельного порта не нужно, а паттерн Go 1.22 ограничивает метод. Дефолтный реестр promhttp отдаёт не только ваши метрики, но и go_ (горутины, память, GC) и process_ (CPU, файловые дескрипторы) — здоровье процесса бесплатно. Эндпоинт отдаётся по HTTP так же, как RPC, — TLS из тринадцатого урока защищает и его.

Что окажется на /metrics, кроме ваших notes_rpc_* метрик?

Каталог метрик RPC-сервиса#

МетрикаТипМеткиНа какой вопрос отвечает
notes_rpc_requests_totalCounterprocedure, codeсколько вызовов и с какими исходами, включая отказы auth
notes_rpc_duration_secondsHistogramprocedureлатентность и хвосты: p50, p95, p99 по методам
notes_rpc_in_flightGaugeнетсколько вызовов исполняется прямо сейчас — насыщение
notes_rpc_stream_messages_totalCounterprocedure, directionобъём сообщений в стримах, входящих и исходящих
notes_rpc_request_bytesHistogramprocedureраспределение размеров входа против порога сжатия и лимита
go_ и process_разныенетздоровье процесса: горутины, память, GC, CPU

Соберите фиксацию кода и меток

Восстановите извлечение кода ошибки и лейблы счётчика в WrapUnary.

start := time.Now() res, err := next(ctx, req) code := connect.CodeOK if err != nil { var connectErr *connect.Error if errors.(err, &connectErr) { code = connectErr.() } } rpcRequests.WithLabelValues(, code.String()).Inc() rpcDuration.WithLabelValues(req.Spec().Procedure).Observe(time.Since(start).Seconds())

Путь одного вызова через слой метрик

Расставьте события одного unary-вызова GetNote в правильном порядке.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: метрики Connect

Термин

RAW-метод для RPC

нажмите, чтобы перевернуть
Определение

Rate, Errors, Duration: частота вызовов (счётчик), доля ошибок (метка code), длительность (гистограмма). Всё измеряется одним интерсептором, который видит процедуру и исход каждого вызова.

1 / 6

Что дальше по курсу#

Слой наблюдаемости собран: интерсептор измеряет каждый вызов, счётчик хранит исходы, гистограмма — длительности, датчик — насыщение, /metrics отдаёт всё это тем же сервером, что и RPC. Схема сервиса отныне меняется под наблюдением — а менять её придётся: следующий урок — версионирование схем, эволюция proto без поломки клиентов: восемнадцатый. Затем — интеграция с фреймворками net/http, где хендлер встанет в чужие mux и цепочки middleware: девятнадцатый. Для повторения: механика перехватчиков и луковица — в седьмом уроке, стриминговые соединения — в восьмом, коды ошибок и errors.As — в шестом, порог сжатия, который вы теперь проверяете метриками, — в четырнадцатом. Программа всех двадцати пяти уроков — на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Мониторинг connect — интерсептор: он видит процедуру, исход и длительность каждого вызова и один на все методы; RAW-метод — rate, errors, duration.
  • Старт по умолчанию — официальный otelconnect одной опцией; собственный слой Prometheus даёт контроль над именами, метками и бакетами.
  • Счётчик с метками procedure и code: код извлекается errors.As из connect.Error; отказ auth виден как code=unauthenticated — обещание тринадцатого урока.
  • Гистограмма с бакетами вместо среднего: Prometheus считает p95 и p99, среднее прячет хвост; длительность — в секундах.
  • Стримы — тот же интерсептор плюс обёртка conn через встраивание: счётчик успешных Receive и Send с меткой direction.
  • Карточность — главная цена: метки только с ограниченными множествами (procedure, code); user_id, IP и URL — в логи и трейсы, не в метки.
  • Размеры входящих сообщений из Content-Length связывают порог сжатия четырнадцатого урока и лимит тринадцатого с живым трафиком.
  • /metrics на общем mux: promhttp.Handler() плюс бесплатные go_* и process_* метрики рантайма и процесса.

Домашнее задание

Оденьте notes-сервис из третьего урока в полный слой метрик и посмотрите на него глазами дежурного. Шаг первый: декларируйте четыре метрики урока — счётчик с метками procedure и code, гистограмму длительности с бакетами, датчик in-flight, счётчик сообщений стрима с direction — и зарегистрируйте их в init. Шаг второй: напишите metricsInterceptor со всеми тремя методами: WrapUnary с errors.As и двумя наблюдениями, WrapStreamingHandler с обёрткой countedConn, WrapStreamingClient как pass-through. Шаг третий: соберите mux с GET /metrics через promhttp.Handler(), GET /healthz и хендлером сервиса в порядке WithInterceptors(&metricsInterceptor{}, &authInterceptor{}). Шаг четвёртый — проверка трафиком: десяток успешных вызовов CreateNote и GetNote, вызов без токена, вызов с битым токеном, короткий стрим с отключением клиента посередине. Прочитайте /metrics curl-ом и найдите: серию с code=ok, две серии с code=unauthenticated и их разницу, ненулевые receive и send у стрима, go_goroutines рядом. Шаг пятый: поднимите локальный Prometheus со скрейпом раз в пять секунд и постройте три панели RAW-метода; запишите в заметки, какой бакет гистограммы первым перевалит за единицу — это подсказка для пересборки бакетов под ваш профиль.

Личные заметкиПусто
Хранятся только в вашем браузере (Connect · Мониторинг и метрики)

Вопросы и ответы по уроку

otelconnect или самописный интерсептор — что выбрать?

Начните с otelconnect — он даст трейсы и метрики за одну опцию и стандартные атрибуты, а exporter решит, куда они текут. Самописный слой с Prometheus оправдан, когда инфраструктура скрейпит /metrics напрямую и вам нужен контроль над именами, метками и бакетами; оба слоя живут вместе: otelconnect для распределённой картины, свои счётчики для дашбордов сервиса.

Почему порядок WithInterceptors(&metricsInterceptor{}, &authInterceptor{}) именно такой?

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

Как считать размер сообщений для стримов?

Для unary доступен Content-Length входящего запроса в Connect-протоколе — его достаточно для гистограммы размеров. Для стримов рамка сообщения даёт длину на каждый кадр, но из интерсептора её достать нельзя — обёртка conn видит только успех и неудачу. Практичный путь: считать сообщения счётчиком, а байты оценивать на транспортном уровне или сжимать вопрос к порогу сжатия только для unary-трафика, где он и решает.

Термины урока

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Fyne25 уроков~13 чпрактика25 уроков уже доступны

Fyne: десктопные и мобильные GUI на Go

Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.

  • Создавать окна и интерфейсы из виджетов
  • Управлять компоновкой: Box, Grid, Form
  • Рисовать примитивами canvas и строить свои виджеты
  • Связывать данные через binding