Стендап Сьогодні
Що я зробив, що я хочу зробити, і що це все значить.
Повсякденні здобутки в форматі стендапу.
Детальніше в статті
Підписатись на RSS
📢
Канал в Telegram @stendap_sogodni
🦣
@stendap_sogodni@shevtsov.me в Федиверсі
10.09.2023
В що я граю останнім часом
Цієї осені дуже складно обрати між безліччю смачних релізів: вже не можу вирішити між Baldur’s Gate 3, Armored Core 6 та Starfield. Поки граю в інше:
-
Ashes: Aftergow - продовження мода для Doom, про який я вже писав. Деталізація оточень бездоганна. Тут просто приємно мандрувати постапокаліптичними торговельними центрами, ресторанами та станціями.
-
Quake 2 Remastered - ремастер, що вийшов нещодавно, додав до Quake 2 рівно достатньо сучасного оздоблення. Велика роздільна здатність, покращене освітлення, всі старі та ціле нове доповнення. Quake 2 - це чудова коридорна стрілялка та нічого зайвого. Класика як вона є.
-
Detroit: Become Human - раніше уникав цієї гри, бо зовсім не резоную з її темою. Проте ось що в ній цікаво: сюжет має десятки суттєвих розгалужень в залежності від твоїх дій — правильних та хибних — та після кожної глави можна побачити дерево рішень. Чого тут не вистачає, так це можливості швидко перестрибнути частини сюжету, які вже бачив. Бо я б хотів поекспериментувати з різними комбінаціями. Оскільки в Detroit немає “game over”, а гра завжди йде далі, то зараз роблю найцікавіший експеримент: дивлюся, наскільки можна зіпсувати сюжет поганими рішеннями.
-
The Centennial Case: A Shijima Story - FMV-детектив (тобто фактично, кінодетектив, в якому розвʼязки маєш обрати ти). Логічні ланцюги інколи ведуть не туди, куди думаєш, але, як каже протагоніст в перші хвилини, “в детективній історії траплятимуться підказки до таємниці, та якщо уважно стежити за ними, вони приведуть тебе до відповіді”. Тому приємно, що навіть коли не доходиш до розвʼязку сам, то принаймні ясно, які прикмети не помітив. До того ж гра є утішною цифровою подорожжю в Японію — бо зроблена японцями про японців для японців (наполегливо рекомендую увімкнути японську озвучку.)
09.09.2023
Typescript обмежує
На мою думку, найстрашніша помилка при переході з JavaScript на TypeScript - це очікувати, що доведеться тільки додати до свого коду типи, а структуру кода та підходи можна не змінювати. Так і зʼявляються десятиповерхові типи, неможливі для розуміння.
-
Підхід з гнучкими аргументами, дуже популярний в JavaScript. Наприклад, коли функція може приймати опції та колбек, або тільки опції, або тільки колбек, або колбек як одну з опцій… Така функція утворює складні для розуміння типи з багатьма альтернативами — а значить, поганий досвід використання. Згадаємо, що такий підхід зʼявився саме як спроба покращити життя споживачам, які можуть помилитись з порядком аргументів — проблема, яку вже вирішує TypeScript. Тому краще зупинитись на одній манері виклику функції- тоді у споживачів буде чітке розуміння.
-
Обʼєкти з нечітко визначеним набором полів. Теж типове явище в JavaScript. Поля наповнюються в різні моменти життєвого циклу обʼєкта. Зазвичай ми здогадуємось про наявність того чи іншого поля за контекстом. Наївний підхід в TS - це перевіряти кожне поле на наявність — або не перевіряти зовсім, а присипати код оператором
!. Це нівелює всі переваги від використання TypeScript. Краще робити вузькі типи для кожної конкретної ситуації. Це також допоможе формалізувати логіку та описати перебіг даних. -
Функції, які доповнюють обʼєкти додатковими атрибутами. Як широко відомий приклад — функція connect в Redux. Доповнення обʼєктів призводить до жахливих типів; бо, наприклад. доведеться обмежувати, що вхідний обʼєкт не мав атрибутів, що додаються. Краще не доповнювати обʼєкти, а повертати нові. Або принаймні якщо доповнювати, то фіксованим набором атрибутів.
Так само як складні тести сигналять про поганий факторинг коду, складні типи сигналять про нечіткий життєвий цикл даних. Такі та інші “нечіткі” ситуації треба перекладати на код з простішими типами. А також не писати нового коду на TypeScript зі своїми нечіткими підходами з JavaScript.
08.09.2023
Завантаження всіх записів з Кафки
Стикнувся сьогодні з ще однією особливістю Кафки. А саме: у топіків Кафки немає кінця. Якщо дивитись не неї як на чергу, то це логічно. Проте оскільки Кафка також є сховищем даних, то все ж трохи дивно не знати, де ті дані закінчується.
API Кафки побудоване на очікуванні нових даних, якщо їх немає напоготові. Типовий споживач буде просто отримувати дані, поки його не зупинять. Але що якщо ми хочемо звантажити все, що є на цей момент — наприклад, для експорту чи резервної копії? Навіть якщо встановити порожній інтервал очікування, ми все одно ризикуємо потроху отримувати все новіші записи, які надходять, поки ми обробляємо попередні.
Натомість потрібно встановити власний критерій зупинки. Простіше за все дивитися на поле Timestamp, яке є у кожного запису. Коли таймстемп перебільшить час початку споживання — значить, пора зупинятись.
Взагалі, корисно знати, що окрім власне ключа та змісту у записів ще є відбиток часу та можуть бути метадані. Ці дані легше (та швидше!) отримати, ніж займатися розбором змісту.
Залишається ще тільки один нюанс: треба памʼятати, що топік ділиться на розділи, а споживач отримує записи паралельно з різних розділів. Записи в різних розділах ніяк не повʼязані та можуть йти в довільній послідовності. Тому зупиняти споживання треба не тоді, коли побачиш новий таймстемп, а коли закінчаться старі.
07.09.2023
Як типи заміняють тести
Є два різновиди помилок: помилки інтеграції — коли код поєднаний неправильно, та помилки логіки — коли код використаний правильно, але задача була зрозуміла або реалізована невірно.
В ідеалі все, чим має займатись інженер — це реалізація бізнес-логіки. В будь-якому разі на логіку доведеться писати тести. Але незалежно від мови, при цьому ми будемо неправильно розуміти та використати код, тобто робитимемо помилки інтеграції — будемо передавати рядок замість обʼєкту, повертати null де очікується значення, і так далі. Витрати на помилки інтеграції не вирішують бізнес-задачі — для бізнесу вони є марними.
В мові з динамічною типізацією перевірити, що код написаний правильно, можна тільки запустивши його. Тому хороший набір тестів має запускати весь код та хоча б в більшості комбінацій параметрів. Навіть юніт-тести в динамічно типізованій мові здебільшого викликають ті модулі, на які мають залежність, бо без того тест не виконує половину своїх обовʼязків.
В мові зі статичною типізацією саме типи й підтверджують, що код інтегрований вірно. А саме, що функції приймають та повертають значення таких типів, як це очікується. До того ж перевірка типами застосовується до абсолютно всього коду, при тому, що зазвичай немає потреби всюди явно оголошувати типи — компілятори добре вміють їх виводити автоматично. Це звільняє нам час краще перевірити логіку. Тобто призводить до якіснішого коду.
Звісно, моделі типів не є строго статичними чи динамічними, вони існують на шкалі, тому в кожній мові баланс типів та тестів свій.
06.09.2023
Про те, як я колись переписував код на асемблері
В продовження теми про оптимізацію низького рівня, колись мені довелось переписати пару функцій на асемблері, щоб досягнути суттєвого прискорення.
Мова йде знову про бітові мапи. Одна з ключових операцій з ними — підрахунок кількості “одиничних” бітів в числі (а точніше, в масиві чисел довжиною в мегабайт або більше.) Окрім очевидного підходу “в циклі зсувати число та перевіряти нижній біт” є набагато більш ефективна формула ваги Гемінга. Але максимально ефективно буде використати інструкцію процесора, яка робить саме те, що нам потрібно: POPCNT.
На той момент (2016 рік) готового рішення для використання її з Go не було (А зараз є, наприклад, модуль go-popcount.) Проте інструкція була настільки смачна, а операція настільки важлива для нашого проєкту, що я вирішив власноруч її інтегрувати.
Go підтримує код на асемблері без додаткових засобів. До модуля можна просто додати файл .s з реалізацією однієї або декількох функцій. Звісно, такий файл буде обмежений однією з архітектур, тому можна також написати реалізацію за замовчуванням - на Go.
Не потрібно навіть знати в досконалості структуру цього файлу. Командою objdump можна декомпілювати програму або модуль в код на асемблері. Тобто послідовність дій така: пишемо першу версію функції на Go, компілюємо, декомпілюємо, після чого можна переписати тіло функції, як хочемо. Звісно, розуміння асемблера все одно необхідно — знати регістри, моделі адресації та таке інше.
Пізніше переписав ще одну функцію — вона рахувала біти в перетині двох масивів. Так вдалося замінити звичайний цикл Go на оптимізований цикл на асемблері, де інкрементується адреса, розташована в регістрі процесора (що набагато швидше).
Не знаю, чи буде в житті ще один такий випадок, але принаймні можу в резюме написати, “професійний асемблерист”.
05.09.2023
Вирівнювання структур в Go
На роботі поділилися цікавою статтею. У двох словах: Golang вирівнює кожний елемент структури так, що він розташовується в памʼяті на межі, кратній його розміру (наприклад, int64 - на адресі, що кратна 8 байтам.) Таким чином, якщо в структурі перемішані поля byte та int64, можна витратити набагато більше памʼяті, ніж очікуєш (а byte використовують, щоб заощадити памʼять, чи не так?)
Нащо це робиться? Бо це прискорює доступ на самому базовому рівні, тобто читання даних з оперативної памʼяті до регістрів процесора. Така оптимізація не втратила своєї актуальності й досі. Без вирівнювання можна отримати в рази гірший час виконання. А деякі інструкції процесора взагалі обовʼязково вимагають вирівнювання. Гарне детальне пояснення є тут.
Та, мова Go достатньо абстрагована, щоб робити вирівнювання автоматично. Для порівняння, на C структура є буквально відображенням області памʼяті на набір полів, тому ніякого вирівнювання не відбувається та ми вільні власноруч обирати, де розташовується кожне поле — навіть якщо це означає стріляти собі в ногу.
Залишається питання — чому тоді Go не оптимізує порядок полів також для економії пам’яті? Я знайшов відповідь — це просто складно. Є пара обговорень конкретно про це. Поки що навіть з автоматичним вирівнюванням є багато нюансів.
Зате існують напівавтоматичні інструменти: правило fieldalignment для govet - його треба явно увімкнути в golangci-lint. Та оптимізатор gopium. Я спробував fieldalignment на нашому проєкті — він знайшов купу “покращень” - але в більшості випадків я б віддав перевагу осмисленій послідовності полів, а не перемішаній заради оптимізації. Хіба що якщо це структура для внутрішньої бази даних, що зберігатиме тисячі записів.
04.09.2023
Пригоди з живленням через USB-C
Напередодні випуску iPhone 15, який, за всією логікою, буде мати порт USB-C, хотілося б проговорити пару нюансів з використанням USB-C для зарядки. ʼ
Бо в USB-C не тільки безліч протоколів та режимів для даних, але й з живленням теж не все просто. Якщо коротко, то в кабелі USB-C є пара жил, по якій пристрій повідомляє зарядці, яку напругу він очікує.
-
Нещодавно дізнався, що зарядки USB-C взагалі відмовлять в струмі, якщо пристрій не комунікує про свої можливості. Я це виявив з ретро-консоллю Anbernic RG353M. Хоч вона й має для зарядки розʼєм USB-C, але зарядити її можна тільки кабелем “USB-A - USB-C”. Кумедно, що якщо такий кабель доповнити перехідником USB-C-USB-A, щоб знов з обох кінців стало USB-C, то такий франкенкабель працює з усіма зарядними пристроями. (Через те, що на переході в USB-A втрачається функція “розумного живлення”.) Так само тільки через франкенкабель заряджається моя лампа з гільзи - в ній, напевно, надто простий контур живлення. Тобто мрія про єдиний кабель USB-C, яким можна заряджати всі пристрої, поки не здійсниться.
-
З позитивного: оскільки зарядні пристрої USB-C здатні видавати струм з напругою 5, 9, 12, 15 та навіть 20V (в залежності від потужності зарядки), то технічно вони підходять для живлення всяких побутових пристроїв. Наприклад, роутеру. Для цього є перехідники “USB-C to barrel plug” - вони містять мікросхему, яка перемкне зарядний пристрій на потрібну напругу. Так можна заживити роутер від павербанка, що може невдовзі статися до нагоди. А ще є універсальні блоки-“клієнти” USB-C - наприклад, fpx.
03.09.2023
Для чого гарний ElasticSearch?
Нарешті, з цими новими знаннями, можу відповісти на питання — що (окрім повнотекстового пошуку) доцільно робити на ElasticSearch.
-
Системи аудиту журналів. Це зараз, напевно, найпопулярніше застосування, принаймні виходячи з документації та статей. Журнальні записи — це текст, тому повнотекстовий пошук стане до нагоди. Але й структуровану інформацію теж зможемо проіндексувати. Також багато чого в ElasticSearch задумано саме для часових рядів — наприклад, коли групуєш записи в індекси по днях, то старі записи можна дуже ефективно видаляти цілими індексами.
-
Системи підбору товарів. Тут не тільки зручно та швидко можна поєднувати умови, як ми вже бачили, завдяки бітовим мапам. А й додавати до товарів потрібний набір полів: завдяки динамічним шаблонам легко задати правильну індексацію на майбутнє. Набагато простіше, ніж з реляційною базою, де для ефективного пошуку практично необхідно будувати таблицю під всі наявні поля.
-
Системи гнучкої аналітики. Тобто коли умови та стани не відомі заздалегідь, а будуть задані користувачами. Тільки треба памʼятати, що кожна “аналітична одиниця” має бути зібрана в один документ. Наприклад, якщо аналітика відбувається за користувачами, то всі дії користувача потрібно записувати разом. Коли приходиш з реляційної бази даних, то це здається не тільки не очевидним, але й суперечить базовим принципам нормалізації. Проте в ElasticSearch навпаки, практикується повна денормалізація.
02.09.2023
Чим пошук в ElasticSearch відрізняється від PostgreSQL
Наступне для мене питання — в PostgreSQL теж є індекси. Чим тоді ElasticSearch краще?
Взагалі, якщо почитати документацію про PostgreSQL, або подивитись це гарне відео, то можна помітити, що аналоги індексам ElasticSearch тут теж є. Індекси GIN то є інвертований словник, який теж зберігає групу документів, в яких є те чи інше слово. Індекси GiST то більш-менш те саме дерево пошуку, як ElasticSearch використовує для чисел та геолокацій.
Щоправда, я не бачив, щоб GIN/GiST використовували для простих значень — рядків або чисел — так, як це робить ElasticSearch. Ну, може я чогось не знаю. Але річ не в тім. Як я розумію, справжня перевага ElasticSearch у швидкому поєднанні багатьох індексів.
Як я вже писав, ElasticSearch для кожного ключового слова зберігає бітову мапу документів, які йому відповідають. Це значить, результати роботи всіх умов пошуку мають форму бітових мап. Бітові мапи можна надзвичайно швидко обʼєднувати бітовими (тобто логічними) операціями — щоб отримати остаточну множину документів, які відповідають всім умовам — результат пошуку.
Бітові мапи настільки ефективні, що PostgreSQL їх теж використовує. Мабуть, доводилось в EXPLAIN бачити назву bitmap scan? Щоб поєднати два індекси (а точніше, дві умови, що їх використають), PostgreSQL будує бітові мапи для рядків, які відповідають кожній умові та поєднує їх бітовими операціями.
Тільки різниця в тому, що в ElasticSearch бітові мапи завжди готові до використання, а PostgreSQL ще має їх побудувати з індексу. Та також, в PostgreSQL дані нормалізовані, тому їх доведеться зводити до купи з різних таблиць, де будуть використані різні індекси, які вже так просто не поєднаєш.
До того ж PostgreSQL підтримує транзакції, а це значить, що для кожного рядка він має також перевірити, чи рядок видимий поточній транзакції (в тому числі чи не видалений він.) В ElasticSearch такий крок не потрібний, а перевірка на видалення робиться ще одною бітовою мапою. (До речі, в обох базах оновлення запису відбувається через видалення старої та додавання нової версії.)
Отже, зорієнтованість на швидкий пошук за багатьма умовами, денормалізація та відсутність транзакцій робить ElasticSearch швидше за PostgreSQL.
01.09.2023
Як OpenSearch індексує числа
Вчора я написав трохи застарілу інформацію: насправді десь з 2016 року Lucene вже не зберігає числа у вигляді рядків. Тобто, така можливість залишилась, але тепер є інший спосіб індексування: k-d дерево. Я не пишу “кращий”, бо все залежить від того, що з ним робити.
K-d дерево це дерево множин точок у K-вимірному просторі. Кожен рівень дерева ділить множину навпіл за одним з вимірів. Таким чином, дерево є збалансованим, тобто має рівну кількість точок в кожній гілці — а значить, мінімальну кількість розгалужень.
Але ми хотіли індексувати числа. До чого тут якісь простори? Так, технічно число задає точку в 1-вимірному просторі, а узагальнення на багато вимірів дозволяє використати ту саму структуру даних також для географічних координат, діапазонів та інших застосувань, де важливо порівнювати одночасно декілька показників. У випадку чисел, дерево просто ділить діапазон навпіл, поки не досягне зазначеного розміру множини, що залишається.
В Lucene K-d дерева зберігаються окремо від інвертованого індексу (словника, про який я писав вчора.) Тобто заради ефективності пошуку архітектори впровадили цілу нову структуру даних та формат файлу. З цього боку зрозуміло, чому обрали саме k-d дерева, які покривають багато потреб, а не тільки пошук чисел.
Пошук в K-d дереві відбувається завжди за діапазоном. Кожний вузол дерева визначає діапазон значень всередині. Він може бути або цілком всередині, або цілком ззовні, або перетинати діапазон пошуку. Ті вузли, що всередині, додаються до результату, ззовні — відкидаються, та тільки в ті, що перетинають, доведеться спускатись на наступний рівень та дивитись на менші підмножини. Таким чином ми маємо справу з найбільшими можливими множинами, та відповідно — з найменшою їх кількістю.
Тепер, про доцільність такого індексу в контексті чисел. Якщо вам потрібно шукати числа за діапазоном, то він ефективний, питань нема. А якщо числа — це ідентифікатори, а шукати потрібно тільки по рівності, то алгоритм залишиться такий самий — хоч для діапазону з одного значення. Ба більше, оскільки множини в k-d дереві не доходять до такої деталізації, то наприкінці пошуку відбувається перебір — не дуже великий, але все ж зайвий.
Тому для індексації чисельних ідентифікаторів краще брати тип keyword. Тоді для них буде використаний звичайний інвертований індекс, оптимізований саме для пошуку конкретного значення.

