Содержание урока · 11 разделов
- Что видно из интерсептора: RAW-метод на RPC
- Свой слой Prometheus: счётчики, гистограммы, датчики
- Интерсептор метрик: unary от и до
- Стримы: тот же код + подсчёт сообщений
- Размеры сообщений: исполнение обещания четырнадцатого урока
- Карточность: главная цена и дисциплина меток
- Каталог метрик RPC-сервиса
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
У вашего 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 одной опцией
GOimport "connectrpc.com/otelconnect"
path, handler := notesv1connect.NewNoteServiceHandler(
¬esServer{},
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-сервиса
GOimport (
"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
GOtype 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 для счётчика сообщений
GOvar 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
GOmux := 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(
¬esServer{},
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_total | Counter | procedure, code | сколько вызовов и с какими исходами, включая отказы auth |
| notes_rpc_duration_seconds | Histogram | procedure | латентность и хвосты: p50, p95, p99 по методам |
| notes_rpc_in_flight | Gauge | нет | сколько вызовов исполняется прямо сейчас — насыщение |
| notes_rpc_stream_messages_total | Counter | procedure, direction | объём сообщений в стримах, входящих и исходящих |
| notes_rpc_request_bytes | Histogram | procedure | распределение размеров входа против порога сжатия и лимита |
| go_ и process_ | разные | нет | здоровье процесса: горутины, память, GC, CPU |
Соберите фиксацию кода и меток
Восстановите извлечение кода ошибки и лейблы счётчика в WrapUnary.
Путь одного вызова через слой метрик
Расставьте события одного unary-вызова GetNote в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Не кладите в метки user_id, идентификаторы сущностей, IP, URL с параметрами и любые неограниченные множества — серия на каждую комбинацию живёт вечно, и реестр растёт необратимо. Метки — место для ограниченных измерений, а не для деталей; детальность — в логах и трейсах. И никогда не пишите в метки токены и заголовки из тринадцатого урока: /metrics читается скрейпером, то есть копия секрета едет в систему мониторинга на постоянное хранение.
Соберите дашборд из трёх панелей: rate по процедурам (rate от notes_rpc_requests_total), доля ошибок (отношение серий с кодами, отличными от ok, к общему), длительность (p50 и p99 из гистограммы). Первый алерт — на долю ошибок, второй — на p99 выше бюджета; оба считаются запросами Prometheus без дополнительных метрик. Бюджет латентности зафиксируйте числом до включения сжатия из четырнадцатого урока — иначе нечем будет оценить его эффект.
Код урока — настоящий Go: интерсептор метрик, декларации и монтаж /metrics копируются в проект из третьего урока один в один. Браузерная песочница сайта сетевых вызовов и Prometheus не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а метрики проверяйте curl-ом на /metrics и локальным Prometheus.
Карточки: метрики Connect
Что дальше по курсу#
Слой наблюдаемости собран: интерсептор измеряет каждый вызов, счётчик хранит исходы, гистограмма — длительности, датчик — насыщение, /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-метода; запишите в заметки, какой бакет гистограммы первым перевалит за единицу — это подсказка для пересборки бакетов под ваш профиль.
Личные заметкиПусто
Вопросы и ответы по уроку
otelconnect или самописный интерсептор — что выбрать?
Начните с otelconnect — он даст трейсы и метрики за одну опцию и стандартные атрибуты, а exporter решит, куда они текут. Самописный слой с Prometheus оправдан, когда инфраструктура скрейпит /metrics напрямую и вам нужен контроль над именами, метками и бакетами; оба слоя живут вместе: otelconnect для распределённой картины, свои счётчики для дашбордов сервиса.
Почему порядок WithInterceptors(&metricsInterceptor{}, &authInterceptor{}) именно такой?
Первый интерсептор — внешний слой луковицы: метрики должны оборачивать auth, чтобы видеть и отклонённые вызовы с кодом unauthenticated. Поменяйте порядок — и метрики увидят только прошедшие проверку, а всплеск попыток обхода станет невидимым. Это тот же контракт порядка, что для логов в седьмом и тринадцатом уроках: решите, кто видит отказы, и зафиксируйте порядок комментарием.
Как считать размер сообщений для стримов?
Для unary доступен Content-Length входящего запроса в Connect-протоколе — его достаточно для гистограммы размеров. Для стримов рамка сообщения даёт длину на каждый кадр, но из интерсептора её достать нельзя — обёртка conn видит только успех и неудачу. Практичный путь: считать сообщения счётчиком, а байты оценивать на транспортном уровне или сжимать вопрос к порогу сжатия только для unary-трафика, где он и решает.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding