Стендап Сьогодні 📢 Канал в Telegram @stendap_sogodni
🤖🚫 AI-free content. This post is 100% written by a human, as is everything on my blog. Enjoy!Пости з тегом #Go
11.05.2023
На що гарний Golang у вебдодатках?
Я вже писав статтю про те, на що гарний Ruby on Rails, та я все ще вважаю Rails правильним вибором для вебдодатка. Тобто, якщо додаток написаний на Rails, то скоріше за все не доведеться його переписувати на щось інше.
Але уявимо, що вам треба переконати мене, що додаток треба писати на Go, а не на рельсі. Ой, важко це буде зробити. Бо Go не підготований для створення вебдодатків. Починаючи з того, що мова ця не така гнучка (див. статтю). Але також, на Go не вистачає відразу декількох інструментів, які є в Ruby on Rails. Це й міграції для бази. Й шаблонізатор. Й інтеграційні тести. І це тільки те, чого мені найбільш не вистачає. Писати вебдодаток на Go - технічно можливо, але безглуздо.
Тому пропоную дивитися на роль Go інакше, та переписувати на Go свою базу даних. Бо сильна сторона Go - це утримання складного та великого стану.
В Rails ми делегуємо утримання стану на базу даних. На такому розділенні праці побудований весь сучасний інтернет — тож можна сказати, що воно надзвичайно ефективне. Втім, зустрічаються випадки, коли база не справляється з потребами додатка. Може, бізнес-логіка надто складна, може, обмеження швидкодії не влаштовують. В таких випадках можна пошукати іншу базу, та я б це й радив робити. Можна почати програмувати всередині бази, це теж працює.
Але програмування в базах даних, порівняно зі звичайним, суцільний жах. Тестів немає, деплою немає, і так далі. Тому простіше було б реалізувати логіку стану безпосередньо в додатку. Ось тут вже можна впевнено брати Go. В Go чудова модель рівночасності, яка дуже корисна для керування складними структурами стану. Зробити власну базу легше, ніж здається, бо універсальна база нам не потрібна — тільки те, що використовує додаток. Власне, так NoSQL бази й зʼявляються.
(Звісно, це не єдине застосування Go. Я міркую з боку вебдодатків.)
13.06.2023
Контексти в Golang
Як зупинити код у відповідь на зовнішній фактор? Один з механізмів відомий всім — то команда kill
, якою можна зупинити цілий процес. Але, як зробити це всередині програми? Що робити, коли блок коду займає більше часу, ніж ми можемо чекати? Це реальні питання, які часто не мають гарної відповіді. Ось в Ruby, наприклад, є модуль Timeout
, який наполегливо рекомендують не використовувати.
В Go питання зупинки процесів стає ще актуальнішим, оскільки створення одночасних процедур — типова повсякденна робота. Як зупинити всі ті горутіни, коли вони більше не потрібні? Можна накрутити (та я й крутив) додаткові канали для сигналізації. Зупиняємо сервіс — сигналізуємо горутінам.
…Паралельно з цим доводиться постійно передавати в різні бібліотечні функції якийсь “контекст”. Нащо він потрібен той контекст — не знаю. Добре що завжди можна створити новий на місці, наприклад функцією context.Background()
.
Та тільки нещодавно зрозумів, що контексти в Go і є той самий механізм зупинки коду у відповідь на зовнішній фактор. У контексту є метод ctx.Done()
, який повертає канал, що буде закритий, якщо пора зупиняти код. Для перевірки використовується команда select
. Це й треба використати замість самописних механізмів. Більше в документації.
Щоб створити власний корисний контекст, є відповідно функції context.WithCancel
та context.WithTimeout
. Особливо зручно те, що такі функції загортають контекст батьківського рівня, та успадковують закриття ще й від нього.
Для сигналів, про які я вже згадав, є signal.NotifyContext. Сервіс, який себе гарно поводить, має спиратись саме на цей контекст, щоб контрольовано зупинити всі свої компоненти.
Тепер ніяких більше context.Background()
.
17.07.2023
Контексти в Golang - простота
Минулого раз я писав, що контексти в Go існують для зупинки процесу ззовні. С тих пір я намагався вписати їх у свій код, та набув додаткового розуміння.
Розуміння таке: так само як і винятки, контексти не потребують багато уваги, насправді. Не потрібно їх постійно створювати або перевіряти.
Новий контекст потрібний тільки там, де може виникнути потреба скасувати операцію окремо від решти програми. Наприклад, на початку обробки запита HTTP можна створити контекст з обмеженням по часу. Якщо час сплинув, то запит пора зупиняти, а решту сервера — звісно, ні. Окремо цікаво, що контекст запита може успадкувати контекст сервера, та тоді запит буде зупинений не тільки за власною тривалістю, але й при зупинці сервера — якщо така поведінка потрібна.
В інших випадках контекст просто проходить по коду зверху вниз. Що, може, не так й зручно, але тут ще нюанс: контексти потрібні здебільшого там, де відбувається зовнішня комунікація — оскільки саме вона створює неочікувані затримки. В простій бізнес-логіці контексти не потрібні. Хіба що можу уявити, якщо є код, що робить довге обчислення — наприклад, відеокодек — то в ньому має сенс перевіряти стан контексту. (Що, до речі, робиться через конструкцію if c.Err() != nil { return c.Err() }
.)
Нарешті, чи потрібно перевіряти, чи повернула функц
ія помилку context.Canceled
? Насправді ні — таку помилку можна передавати нагору та само, як і будь-яку іншу. Це виконує задачу контексту: код буде зупинений. Тільки на найвищому рівні — там, де контекст був створений — треба перевірити, що саме трапилось — скасування контексту або інша помилка.
]
09.04.2024
Контексти в Golang, осмислення
Контексти, як і тип error
, висвітлюють одну з головних ідіом Go: робити перетік програми очевидним. Навіть в збиток до стислості та легкості написання.
Там, де в інших мовах помилки самостійно “виринають” по стеку механізмом винятків — хтозна-куди — в Go ми примушені перевірити кожну помилку в кожному місці виникнення. Та, в 99% випадків, вручну реалізувати повернення вгору по стеку. (В 1% ми перевіряємо, чи це не io.EOF
або ще якась “прийнятна” помилка.) Це найбільше й дратує — що хоч ми робимо перевірки вручну, але практично з єдиним передбачуваним результатом.
Якщо error
- це найбільш прозорий спосіб повернути стан з середини назовні, то context.Context
- найбільш прозорий спосіб передати зовнішній… контекст всередину коду. Так, це значить, що ми передаватимемо параметр ctx
в кожну функцію нашого проєкту, де є бодай одна операція з мережею. Це включає не тільки багато очевидних функцій, але й деякі такі, де, здавалось би, ніякий контекст не потрібен. Наприклад: функція-конфігуратор, яка проміж іншим створює клієнт AWS, повинна передавати контекст. (Ця проблема нагадує поширення async
по коду в JavaScript.)
Виникає питання — навіщо взагалі це зроблено? Як я писав, контексти існують в першу чергу заради можливості скасування. Як і обробка помилок, це механізм для виняткових ситуацій, тому його цінність не очевидна. Також, контексти нічого не варті, якщо їх не скасовувати — буквально. На щастя, причини скасовувати завжди є. Сервера повинні обмежувати час на відповідь. Програми повинні зупинятись за командою.
Ми всі знаємо, що проблема зависання існує, так само як і проблема несподіваних помилок. Go, на відміну від інших мов, примушує нас не закривати на них очі.
05.11.2024
Теги в каналі та HTML
Не так все просто з тегами, як я вчора розповідав. А саме, замінити тег всередині тексту не можна, якщо він вже є посиланням. Ну, наприклад, якщо я вирішив послатися на сторінку Вікіпедії про Go, а також й додав тег Go, то тегом повинна бути друга згадка, а не перша, яка в посиланні.
Іншою особливістю тут є те, що метадані (разом з тегами) є частиною вихідного тексту, а значить, зʼявляються не до, а одночасно з обробкою Markdown. Тому легше за все замінити теги вже в підготованому для Telegram тексті. Який є потворним гібридом HTML та текстової розмітки. Тож доведеться заміняти в HTML.
Як заміняти? Точно не регулярними виразами. Тут потрібний той чи інший парсер HTML. В Go для того є модуль net/html - я про нього вже згадував. Він надає, окрім іншого, токенізатор HTML, тобто спосіб перетворити HTML в послідовність токенів: “тег”, “текст”, “коментар” тощо.
Для такої задачі токенізатор краще, ніж розбір документа в дерево, оскільки дерево нас не цікавить. Нам важливо тільки одне: чи знаходиться текст всередині посилання. Тому заводимо прапорець “чи ми в посиланні” та біжимо по документу.Бачимо токен “відкрити тег A” - вмикаємо прапорець; “закрити тег A” - вимикаємо прапорець. Якщо зустріли токен “текст” та прапорець вимкнений — шукаємо в ньому теги. А решту токенів копіюємо в вихід, як є.
Замість складного обходу дерева чи пошуку батьків — простий прохід з тривіальним скінченним автоматом. Швидко та прозоро.
08.11.2024
Пошук перегонів даних в Go
🏁 Перегони даних — це коли два рівночасні процеси наввипередки оновлюють змінну, без відома про існування один одного (Мені не дуже зрозуміло, чому це називається перегонами.) В результаті отримуємо напівзіпсоване значення, в якому не врахована частина обчислень. Причому, звісно, рівночасність — непередбачуване явище, тому псування трапляється не кожного разу, а може взагалі чекати на високе навантаження в продакшні.
Одним словом. Шукав, чому випадково хибиться тест. Звузив причину до ділянки з використанням go-redis#ClusterClient.ForEachMaster - вона збирала значення з усіх серверів Redis в один масив. Спочатку думав, що тесту бракує очікування (ще одна класична причина випадкових хиб) та намагався виправити. Тут є легка перевірка, насправді: додай свідомо перебільшене очікування — може, 10 секунд — якщо це не виправляє тест, то проблема в іншому.
А потім помітив в документації по ForEachMaster, що вона-то рівночасна!. Звісно, через рівночасність зміст вихідного масиву був неповним, до того ж для кожного хибного випадку різний. Щоб виправити, я додав збір результатів через канал, але можна було б й мьютекс поставити. Такі API з прихованою рівночасністю мене обурюють, бо з ними хоч знаєш, а не зробиш правильно.
Тут я згадав, що в Go є детектор перегонів та раптом дізнався, що він не увімкнений за замовчуванням. Увімкнув — та дійсно, проблемний тест відразу “засвітився”. Хотів вже увімкнути для всіх тестів та локальних запусків, але виявив пару проблем з цим. По-перше, тести з детектором тривають в три рази більше. По-друге, як не дивно, але для компіляції з детектором потрібно увімкнути CGO, а це йде зі своїм пакетом ускладнень, як-от неможливість кроскомпілювати з macOS в Linux. Довелося відмовитись.
Нарешті, детектор знайшов ще одну гідну обурення ситуацію. Такого я ще не бачив. Офіційних серіалізатор Protobuf у JSON - protojson
- додає у випадкові місця JSON пробіли. Та цього навіть не можна вимкнути!. Пояснюють вони це тим, що “споживачі мусять не очікувати чіткого формату”, оскільки команда Protobuf з ним ще не визначилася (sic!) Тому поки відстежуємо обговорення, якому вже 4 роки, а в тесті довелося декодувати JSON та порівнювати не рядком, а за змістом.
21.11.2024
Розширена інформація після невдалого тесту в Go
Захотілося, щоб після невдалого інтеграційного тесту я бачив журнал сервісів з Docker Compose. Бо в мене на CI вже й так журнал друкується наприкінці збірки, але за тим загальним журналом складно знайти потрібне місце.
Перше питання стало — як взагалі в Go викликати код після тесту? Рішення в стандартній бібліотеці не знайшов, але ті тести виконуються пакетом testify/suite
, а в нього є метод TearDownTest(), проміж інших. Тож тут проблем немає.
…Далі, як дізнатися, що тест був невдалий? Я очікував якогось аргументу в колбеці, але ні. Насправді той обʼєкт *testing.T
, навколо якого обертаються тести, має метод t.Failed(). (Взагалі раджу його вивчити детальніше, бо там багато чого цікавого, наприклад, можливість отримати тимчасову директорію для одного тесту - t.TempDir()
.) А в testify/suite
, до речі, через s.T()
отримуємо той самий обʼєкт.
(PS: а ще в testify/suite
знайшов suite.HandleStats - якщо його оголосити, то цей метод буде викликаний наприкінці всього пакету зі статистикою про час виконання та результати тестів.)
Окей, тепер, як отримати логи? Спробував через програмний клієнт Docker… надто низькорівневий, бо він повертає для логи конкретного контейнера, та ще й потоком. А мені потрібні зведені логи всіх контейнерів в проєкті. Тому просто викликаю консольну команду docker compose
. Для того є зручний високорівневий тип exec.Cmd. Йому можна прямо сказати “спрямуй вивід команди в мій вивід” і все, готово.
14.01.2025
Тести з базою на Go
Як я два роки тому писав, так і досі користуюся бібліотекою testfixtures для підготовки тестової бази. Фікстура — це такий файл (шаблонізованого) YAML, в якому сидить зміст однієї таблиці.
В мене специфіка проєкту така, що є багато складних запитів, тож фікстур доводиться створити чимало. Мені цей підхід дуже не подобається, бо фікстури мають по файлу на кожну таблицю, та логічні звʼязки між записами не проглядаються. Та друга проблема фікстур — немає можливості прибрати обовʼязкові в схемі бази, але несуттєві для тесту значення.
Одним словом, я все більше дивлюся, як уникати бази в тестах взагалі. Для когось це, може, очевидний підхід, але я звик до Rails та factory_bot, де наочно та стисло можна створювати довільні ієрархії обʼєктів. Втім, factory_bot
спирається на ActiveRecord, а для Go в мене нічого схожого немає.
Базу можна замокати — для того є бібліотеки sqlmock чи pgxmock. Вони дають можливість задати результат для кожного запиту. Що приємно, не обовʼязково писати весь запит, достатньо підрядка, наприклад, з використанням sqlc вистачить назви запиту.
З моками бази зручно, що запити будь-якої складності заміщаються легко: достатньо вказати, які рядки він має повертати. Мені тільки не подобається, що моки нічого не знають про структуру бази, тому легко помилитися зі змістом або забути оновити моки після міграцій. В результаті будемо тестувати із хибним прикладом та не знати краще.
Тому мені ще більше подобається підхід вищого рівня. Оскільки код, що виконує запити, ми генеруємо з sqlc, тобто його тестувати не потрібно, то можна мокати згенерований інтерфейс. Він повертає структури, цілісність яких гарантує (в міру можливого) sqlc, а для моків можна використати хоч mockery, хоч будь-яке інше рішення, бо до бази вони вже не мають відношення.
Для повного комплекту гарно ще вкрити тестами модуль sqlc
, щоб переконатися що самі запити працюють вірно.
04.02.2025
Обробка помилок: слова-паразити в Go
Обробка помилок в Go в мене викликає асоціацію зі словами-паразитами на кшталт “ну”, “типу”, “е” тощо. Тільки й різниці, що на відміну від звичайних паразитів, вона необхідна для вірності програми.
res, err := client.Get("https://api.site.com")
if err != nil {
return nil, fmt.Errorf("can't get: %w", err)
}
err = json.Unmarshal(res, &data)
if err != nil {
return nil, fmt.Errorf("can't unmarshal: %w", err)
}
В 9 випадках з 10 якщо викликана функція повертає помилку, то ми повертаємо помилку зі своєї функції також. Таким чином, ця ідіома плодиться на більше й більше викликів — та її потрібно писати після кожного. Виходить, часто буквально більше рядків передають помилки нагору, ніж роблять роботу.
Причиною цього, гадаю, є свідомо обмежений синтаксис Go, в якому немає особливих конструкцій для обробки помилок, а з наявними конструкціями нічого краще не зробиш. (Хоча, можливість повернути декілька значень з функції існує саме заради повернення помилок — наскільки я знаю.)
Враховуючи те, що ця ідіома одна з найпоширеніших, можна тільки сподіватись, що в Go 2 щось додадуть на заміну. Наразі в трекері 12 відкритих та 182 закритих пропозицій з обробки помилок — тема дійсно гаряча. Особисто мені подобається res := try fn()
(причому помилка неявно передається нагору) або res := fn!()
- тобто щоб того блоку обробки взагалі не було.
05.02.2025
Контексти: слова-паразити в Go
Друга, новіша ідіома в Go, яка засмічує код — це контексти. Я вже писав, що контексти надійно розвʼязують складну проблему скасування операцій, в тому числі тайм-аутів. Але також передача контекстів зроблена… скажімо так, якби це придумали в нас на проєкті, то я б всіма силами протистояв.
(Це не така вже й уявна ситуація, бо одного разу я запропонував PR з повним покриттям контекстами для всіх функцій, що викликають інші функції, що приймають контекст. А потім сам же ж закрив той PR, бо код ставав значно гірше, а переваги значними не були.)
func (s *Service) GetUserBalance(ctx context.Context, id int) (*Results, error) {
cachedResults, err := s.cache.Get(ctx, s.cacheKey(id))
if err != nil { // і цього не забудемо
return nil, error
}
if cachedResults {
return cachedResults, nil
}
user, err := s.queries.GetUser(ctx, id)
// ... and so on
}
Причому контексти самі собою нічого не скасовують. Це задача самої функції. На практиці вони впливають тільки на операції вводу-виводу. Щоб скасувати, скажімо, тривалі обчислення, доведеться час від часу перевіряти стан контексту вручну.
Але головне, що, як і з помилками, обробка контекстів в 99 зі 100 випадків однакова: вони тільки передаються далі. Гадаю, було б краще, якби в Go 2 контекст передавався в кожну функцію сам собою, за замовчуванням. Або ще краще, якби його передача відбувалася тільки там, де компілятор бачить в контексті потребу (тобто залежність від функцій, які самі потребують контекст.)
Такі зміни могли б відбутися на рівні синтаксису, без зміни реалізації. Власне, навіщо? Бо мені не подобається, коли значна частина коду не несе змісту. Майже завжди передача контексту чи перевірка помилки не має нюансів, важливих для бізнес-логіки. Тому вони варті спрощення — так само як ми можемо не писати тип більшості змінних, чи опускати назви полів структури.
06.02.2025
Go - що це за мова?
В мови Go, беззаперечно, є власний настрій. Та якщо його не вхопити, можна довго боротися з домовленостями та неписаними правилами, яких в Go дуже багато. Go - не та мова, де приємно виражати власний стиль.
Для мене Go - це мова синіх комірців. Не академічна. Не поетична. Не модна. Робітнича. Але при тому Go ніяк не є архаїчною мовою — такі аспекти, як керування залежностями, знаходяться на передній межі сучасності.
Go точно не є мовою, дружною до новачків, бо очікує від програміста розуміння деяких низькорівневих сутностей — як вказівники чи типи даних, та суворого дотримання деяких правил — як відсутність зайвих оголошень та імпортів.
Для мене Go - мова “бери та й роби”. Мова YAGNI. Мова “я встигну це за вихідні”. Мова “мене цікавить результат, а не процес.” Мова для інженерів, а не програмістів.
(Але якимсь чином цей підхід тут масштабується, бо на Go написані такі величезні проєкти, як Docker, Kubernetes чи Terraform, а також Hugo, Grafana, Ollama та багато всього іншого.)
Найкращий спосіб зануритися в Go - це мати реальну задачу, яку дійсно потрібно розвʼязати. Та рухатись до розвʼязку найбільш прямими та простими кроками, обовʼязково з оглядом на домовленості. І все тоді вийде.
19.02.2025
Мистецтво назв пакеті в Go
Ще одна унікальна особливість Go - підхід до організації пакетів. Тут пакетом є кожна директорія. Без вкладеності, вкладені — то вже окремі пакети. Я таке бачив ще хіба що в Terraform… а на чому написаний Terraform? На Go, то-то.
Саме пакет визначає межі приватності ідентифікаторів. (Чи знаєте ви, що в Go ідентифікатор з маленької літери є приватним, а з великої — публічним? Причому що типи, що змінні, що константи можуть бути як з маленької, так із великої. Ще одна абсолютно інопланетна особливість.)
За межами пакета імена включають його імʼя: mypkg.Type
. (Є ще “імпорт всього”, але він майже не використовується.) Причому хоч назвою пакета є фактично його URL, та кожний імпорт згадує його повну форму: import "github.com/me/mypkg/subpkg"
, та в коді шлях не згадується. (Можна ще імпортувати з перейменуванням, бо без нього швидко опинишся із конфліктами.)
Це все довгий вступ до того, що в Go до йменування пакетів потрібно ставитися з великою відповідальністю. Зокрема, вони повинні бути стислими, бо постійно згадуються в коді. А тепер — ще стислішими. Два слова? Пиши разом, скорочуй.
Звісно, це накладає особливий стиль. httputil
, textproto
, strconv
, slog
- це тільки приклади зі стандартної бібліотеки.
Втім, з власними пакетами складніше, бо рідко зустрінеш стале скорочення. Тоді в допомогу стає розділення імені. Наприклад, ніхто не забороняє зробити вкладені директорії: service/queries
, api/v1
. Це часто буває просто зручно: наприклад, коли пакети service1/config
та service2/config
будуть використані всередині відповідного сервісу, то з контексту й так зрозуміло, що то за config
, а у винятку зробити import service1config "service1/config"
.
Також є домовленість виносити назву типу в назву пакета. Наприклад, http.Server
або service1.Request
. Хоча не завжди це виходить очевидно. Краще, коли пакети маленькі та тісно звʼязані (це взагалі краще.)
Взагалі. Успіх проєкту на Go дуже залежить від вірної організації пакетів. Про це, може, іншим разом.