Використання парсер-комбінаторів в C#: від теорії до практики
Усім привіт. Чи виникала у вас колись задача перетворення сирого тексту в чітко структуровані, гарно типізовані дані? Звісно виникала, всі ми як девелопери маємо справу хоча б з одним форматом даних (JSON, XML, YAML тощо). Для цих форматів є купа готових та якісних інструментів. Але що робити, якщо готового інструменту немає? Якщо формат нестандартний, більше того, це взагалі примітивна мова задання обмежень?
Мене звуть Юрій Науринський, я .NET/C# Technical Lead команди MapService в Uklon. Розповім вам про техніку побудови високоякісних інструментів для перетворення примітивних даних в структуровані та покажу це на прикладі, з яким стикнувся під час, здавалося би, простої задачі обчислення часу перекриття доріг. Я розкажу вам про парсер-комбінатори.
Постанова задачі
Взагалі в нас є два варіанти: почати з сухої теорії або з цікавого практичного прикладу. Почнемо зверху вниз — з практики. І поступово, за шматочками, зберемо наявні умови, а з них і задачу.
Як ви вже напевно здогадалися, команда MapService займається чимось, пов’язаним з картографією. А конкретно — пошуком адрес (автокомплітом) та побудовою маршрутів. Під час покращення якості побудові маршрутів ми й стикнулись з один моментом, про який краще розказати на прикладі.
У Києві є така чудова вулиця під назвою «Паркова дорога» з вельми цікавими правилами руху: в робочі дні в години пік односторонній: до центру —
OSM
Ми використовуємо OSM, тож трохи контексту, як там влаштовано зберігання інформації. У нас є 3 ключових складові:
● node
— одна точка;
● way
— набір точок;
● relation
— абстрактне відношення, яке може містити в собі в довільній комбінації довільні node, way, relation.
Всередині кожного елементу інформація зберігається у вигляді списку тегів. Тег — пара ключ-значення. Якщо гарними картинками, то виглядає ось так:
Наразі, ми маємо таку інформацію:
- в нас є тег access:conditional;
- він містить в собі інформацію про те, чи перекрита дорога, чи ні, і в який час.
Conditional restrictions теги
Це біль. Як взагалі задати інформацію про те, чи перекрита дорога, чи ні, і в який час? Для цього в OSM був придуманий спеціальний синтаксис:
<restriction-type>[:<transportation mode>][:<direction>]:conditional = <restriction-value> @ <condition>[;<restriction-value> @ <condition>]
access:conditional=no @ (Mo-Fr 16:40-19:45; PH off)
, який ми вже бачили є типовим прикладом:
access - <restriction-type>
— що саме ми обмежуємо (в цьому випадку проїзд через дорогу);<transportation mode>
— пропущено, бо, вважається, що це обмеження діє на всі можливі транспортні засоби, які можуть рухатися цією дорогою (звичайні автомобілі, вантажівки, автобуси тощо);<direction>
— пропущено, дорога перекрита в обидва напрямки (і тут немає сенсу це використовувати, бо дорога і так одностороння);conditional
— обов’язковий суфікс;no - <restriction-value>
— ні, при заданих умовах проїзд через дорогу недоступний (а якби було yes, було б навпаки);(Mo-Fr 16:40-19:45; PH off) - <condition>
— умова, за якої діє обмеження.
Ще декілька прикладів:
- maxspeed:conditional=130 @
(19:00-06:00) - maxspeed:conditional=120 @
(06:00-20:00); 100 @(22:00-06:00) - motor_vehicle:conditional=delivery @ (Mo-Fr 06:00-11:00,17:00-19:00;Sa
03:30-19:00)
Наче нескладно, регулярка в поміч! Не зовсім. Диявол криється в значеннях <condition>
.
Специфікація є ось тут. Діаграма синтаксису виглядає так:
Це тільки початок. Дозволю собі процитувати автора специфікації:
Grammar for time domain values
- Is compatible with the proposals examples (except the time is obligatory).
- Covers all values I found (as formal expression is possible at all).
- The very common plain text conditions can be expressed as «...» comments.
- Can be evaluated sufficiently simple.
Відчуваєте тут щось підступне?
Трохи проспойлерю: це те, що може траплятися, і це валідні значення відповідно до специфікації.
Висновки, які ми зробили з досвіду використання специфікації:
- Специфікація неформальна!
- Це лише набір рекомендацій!
- Ми повинні бути готові до обробки будь-яких значень тегів.
- Повинна бути гнучкість в додавнні обробки нових значень тегів.
Запам’ятаємо два останні пункти, вони допоможуть обрати нам шлях до успіху. А що потім? Потрібно цю інформацію перетворити на щось, що зрозуміє наш рушій побудови маршрутів — OSRM.
OSRM
Своєю чергою, OSRM має можливість інтеграції даних заторів. Достатньо в спеціальному файлі передати дані наступного вигляду (візьмемо приклад з офіційної документації):
25296,25295,24
34491,34494,3
1283974,2387,3
Формат дуже простий: стартовий ідентифікатор way, кінцевий ідентифікатор way, швидкість в кілометрах на годину. Іншими словами, задаємо ребро дорожнього графа обома його вершинами та швидкість на ньому.
Як це пов’язано з перекриттям доріг? Дуже просто — якщо передати швидкість нуль, тоді дорога перекрита.
Задача
Отже задача полягає в тому, щоб виконати три простих кроки.
Хоча кроки перший і останній також доволі цікаві (вартує окремої статті?), наразі ми сконцентруємось на тому кроці, заради чого ми використовуємо парсер-комбінатори: дізнатись з кожному тегу, чи перекрита дорога, чи ні.
Розвʼязання задачі
Отже в нас є тег, з якого ми можемо дістати необхідну інформацію. Це все? Ні, обмеження діє в якихось часових межах, тому нам ще потрібен час, для якого ми обчислюємо доступність дороги.
Дещо формалізуємо задачу. У нас є неструктуровані сирі дані — значення тегу з суфіксом :conditional
(наприклад no @ (Mo-Fr 16:40-19:45; PH off
)).
Потрібно повернути звичайний bool. Трохи обміркувавши задачу, розбиваю її далі на такі кроки:
Добре, тепер в нас є два послідовних кроки розвʼязання задачі, ідемо далі за ними.
Парсинг
Теорія
Що це слово взагалі означає?
Насправді, якщо трохи формалізувати, то парсинг — процес перетворення неструктурованих даних у структуровані. Виконується за допомогою спеціального інструменту — парсеру. Перефразовуючи, парсер — функція, яка приймає на вхід сирі неструктуровані дані та видає на виході результуючі структуровані дані.
Поки що не дуже зрозуміле визначення. Насправді я впевнений, що ви вже стикалися з парсерами раніше (JSON, наприклад). Але що, якщо я скажу вам, що парсери можна... поєднувати між собою?
Типова парадигма в програмуванні: з’їсти слона шматочками. Якщо в нас є вже декілька парсерів, які парсять щось примітивне, виглядає логічним їх об’єднати, щоб парсити вже щось справді суттєве. Але як?
Парсер-комбінатори виходять на сцену:
По суті, це звичайна функція, яка приймає на вхід множину парсерів і видає на виході результуючий парсер. Відчувається аромат функціонального програмування, бо ми тільки що описали функцію вищого порядку. І навіщо нам взагалі ці всі комбінації? Це дає нам можливість довільної побудови дерева парсерів:
Типовим підходом є побудова дерева парсерів знизу вверх — з примітивних маленьких шматочків і далі вверх драбиною складності:
Якщо підсумовувати, весь наш процес парсингу за допомогою парсерів та їхніх комбінаторів виглядає ось так:
На практиці можливість практично довільної комбінації парсерів дає фантастичну гнучкість в побудові практично будь-яких парсерів. А гнучкість нам дуже потрібна...
Нам було б і цього достатньо, але ще одною невіддільною перевагою, яка випливає з їхньої природи, є композиційність:
- натурально випливає з природи парсер-комбінаторів;
- можемо використовувати вже кимось написані парсери;
- можемо ділитись своїми парсерами;
- перевикористаність потужно зростає до небес;
- у результаті композиції ми отримуємо гарантовано валідний парсер. Вельми зручно використовувати систему типів для забезпечення комфорту своєї роботи!
Отже через феноменальну гнучкість та перевикористаність ми обрали підхід з побудовою свого парсера за допомогою парсер-комбінаторів для парсингу значень тегів умовних обмежень.
Альтернативні варіанти парсингу значень тегів умовних обмежень.
Мені дуже сподобався вираз з цієї статті — «Регулярки незбагненні!». Що це значить? Усе дуже просто: коли ви дивитесь на регулярний вираз, чи можете ви одразу сказати, що він робить? Цікаво дізнатись вашу думку, пишіть в коментарях.
Так от, є підозра, що таких надлюдей небагато, тому і вважається, що регулярки write-only — простіше написати нову, ніж редагувати стару!
Це перше велике питання — чи варто використовувати регулярні вирази. Друге — композиційність. Композиційність рівня конкатенації шматків сирих рядків, яка не дає жодної гарантії того, що зрештою ми отримаємо валідний регулярний вираз.
Питання залишається лише одне — воно того варте? Вирішувати лише вам, але пам’ятайте, що у світі завжди є щось більше ніж «комфортні» та звичні регулярки...
Більш просунуті читачі можуть сказати, що варто написати свій самописний парсер, і, насправді, якщо пошукати, то самописні парсери й попадаються (приклад 1, приклад 2, це ще не всі можливі), але:
- нам не потрібно підтримувати всі можливі випадки от одразу, краще їх гнучко додавати за необхідності (коментарі, наприклад, зараз не підтримуються);
- це потребує значних витрат часу та зусиль.
Отже оптимальним варіантом для нас залишилися лише парсер-комбінатори. Можливо, ви бачите інші варіанти?
Практика
Настав час перейти до практики. Як ця вся суха теорія виглядає в реальному коді?
Ми використовуємо мову програмування C#. Залишилося тільки визначитися, яку бібліотеку парсер-комбінаторів обрати. Вибір є: це Sprache, Pidgin, FParsec. Ми обрали Pidgin — швидкий, гнучкий, невеликий. Насправді підійде будь-яка з них, базові принципи роботи незмінні. Різниця лише у специфіці використання.
Парсинг
Для прикладу оберемо таке значення тегу:
Apr,Jun-Aug,Dec 10:00-15:00; Mo-We,Fr 08:00-12:00,13:00-17:30; Sa 08:00-12:00; PH 09:00-12:00
Вираз обчислюється зліва направо, кожне правило розділене крапкою з комою, кожне — перекриває попереднє. Порядок обчислення правил виглядає так:
● У квітні, з червня до серпня, в грудні — робочі години з 10 до 15
— це виходить для усіх днів.
● Але з понеділка до середи та в п’ятницю робочі години
● У суботу робочі години
● Під час свят робочі години
— є два типи свят:
— PH — Public Holidays — звичайні свята;
— SH — School Holidays — шкільні канікули.
Оскільки правила перекривають попередні, тоді правильним є те, що якщо свято припадає, наприклад, на вівторок, то робочі години тільки з 9 до 12. Або для будь-якого місяця в суботу робочі години
Є чудовий сайт, який наочно показує, чи попадає момент часу в задане тегом обмеження. Декілька прикладів:
Час їсти слона! Тестовий вираз виглядає ось так:
Apr,Jun-Aug,Dec 10:00-15:00; Mo-We,Fr 08:00-12:00,13:00-17:30; Sa 08:00-12:00; PH 09:00-12:00
З яких шматочків він взагалі складається? Використовуємо підхід «знизу вверх» та аналізуємо. Перше, що спадає на думку — це час. Примітивний шматочок часу: 10:00. Консультуємось зі специфікацією та визначаємо, які є можливі значення часу.
Нічого екстраординарного ми не побачили, можна сміливо парсити. Перш за все, треба розпарсити двокрапку:
Char — це вже вбудований в Pidgin-парсер. Пам’ятаємо про композиційність! Далі є різні варіанти парсингу годин та хвилин:
- задати кожне можливе значення для години / хвилини;
- знайти щось спільне, щоб використати один парсер для обох елементів.
Що може бути спільного між годинами та хвилинами? Обидва є числами, що завжди складаються з двох цифр. Цього цілком достатньо.
Використовуючи вже вбудований парсер Digit
, парсимо декілька цифр і зрештою отримуємо рядок. Перевіряємо, що довжина рядка дорівнює двом символам, конвертуємо рядок в число.
Зверніть увагу на тип Parser<char, int>
. Це буквально те, що було в теорії. На вхід — сирі дані, тобто послідовність токенів (тобто послідовність літер). На виході — структуровані дані — число. На мою думку, найбільший буст до читабельності дає нестандартне використання LINQ Query syntax, як у наступному прикладі, який парсить уже повноцінний рядок годин та хвилин.
Цікаво, як вам читабельність цього синтаксису? Так, це незвично виглядає. LINQ Query syntax взагалі-то для IEnumerable призначений, але за своєю суттю — це синтаксичний цукор, який можна використати для своїх потреб. Детальніше про цей трюк із синтаксисом можна почитати, наприклад, тут.
У цьому випадку логіка парсингу виглядає ось так:
- Використовуючи вже відомий нам парсер T
woDigitNumberParser
, отримуємо години. - Наступним елементом ми обов’язково повинні розпарсити двокрапку! Сенсу використовувати її значення немає ніякого, можна замінити, наприклад, на нижнє підкреслення. Але, на жаль, C# не сприймає нижнє підкреслення в LINQ Query Syntax як wildcard змінну, тому їх доводиться нумерувати. Це, мабуть, єдиний мінус до читабельності. Чи не єдиний?
- Далі обов’язково ідуть хвилини.
- Перевіряємо, чи валідне число ми взагалі отримали для годин та хвилин.
- Створюємо кортеж з уже перевірених чисел, які означають години та хвилини.
Важливий момент, який варто зауважити. Що буде, якщо наш парсер намагатиметься розпарсити такий рядок: 10.00? Очевидно, що рядок розпарсити не вийде. Парсер розпарсить години, далі зустріне точку, хоча очікувалась двокрапка. Це зупинка процесу парсингу та повертання помилки. Далі процес парсингу не піде (але є можливість це підправити). У типах ця помилка не виявляється, вона схована всередині парсеру.
Наче нескладно? Їмо далі.
Наступним базовим елементом є день тижня. Наприклад Mo, We, Fr
:
Виглядає примітивно. Що спільного у всіх днів тижня? Це завжди рядок з двох символів.
Тоді треба робити парсер за аналогією з парсером годин та хвилин? На практиці ж ми зробили трохи хитріше. У C# насправді вже є чудовий enum під назвою DayOfWeek
:
namespace System; public enum DayOfWeek { Sunday = 0, Monday = 1, Tuesday = 2, Wednesday = 3, Thursday = 4, Friday = 5, Saturday = 6, }
І магічним способом перші дві літери днів тижня збігаються з елементом wday
зі специфікації. Трохи магії, і отримуємо такий парсер:
Логіка роботи:
- Парсимо рядок літер.
- Намагаємося з рядка літер отримати день тижня.
- Фільтруємо по властивості — вдалося / не вдалося.
- Створюємо селектор дня тижня (про селектори трохи згодом в розділі про обчислення).
Трохи дисклеймеру: що за IsSome
та ValueUnsafe()
? Ми використовуємо бібліотеку language-ext, це властивість та метод типу Option, за деталями відсилаю до своєї доповіді.
Рухаємось далі! Що ще нам треба розпарсити? Свята? Легко!
Примітивно.
Єдине питання — що таке CIString? Це означає, що ми парсимо рядок, і регістр символів цього рядка нас не хвилює, підійдуть обидва. Ми можемо дозволити собі розкіш приймати як валідне значення рядок pH, наприклад. Або Ph. У нашому контексті — яке точно свято, а не індикатор кислотності / лужності, наприклад.
Як щодо місяців?
Тут, на жаль, ніяк не схитруєш. Або ми не знайшли спосіб? Тому просто прописали всі значення місяців в окремій колекції.
Знову парсимо рядок і шукаємо серед відомих значень те, яке розпарсили. Якщо валідно, тоді нам пощастило.
Надто примітивно? Час більш абстрактних речей.
В специфікації є така річ як діапазон. Задаємо перший та останній елемент, ставимо між ними тире, і маємо діапазон. В нього входить все, що між першим елементом включно та останнім елементом включно.
Усі діапазони з прикладу:
Jun-Aug 10:00-15:00 Mo-We 08:00-12:00 13:00-17:30 08:00-12:00 09:00-12:00
Задаються вони для кожного типа елемента окремо, але вони узагальнюються і насправді прекрасний кандидат на те, щоб показати усю міць та привабливість абстрактної побудови парсерів за допомогою парсер-комбінаторів.
Діапазон насправді дуже простий — це всього лише початок та кінець зі специфічними роздільниками діапазонів.
Алгоритм роботи парсера діапазонів:
1. Отримуємо парсер елементу діапазону.
2. Отримуємо функцію-фабрику створення структурованих даних з діапазону.
3. Отримуємо функцію перевірки валідності діапазону:
1. ми ж не можемо задати те, що вівторок іде перед понеділком;
2. насправді можемо, і це окремий біль під час обчислення умов, тому поки таку можливість не підтримуємо.
4. Використовуючи зручний, приємний на вигляд та дотик LINQ Query syntax, парсимо наш діапазон.
Як виглядає парсер Dash
— залишу на ваші роздуми. (Корисно писати статті, тільки зараз помітив, що цей парсер насправді повинен називатися Hyphen).
У чому тут міць та привабливість?
Міць. Цей код працює для всіх можливих діапазонів. Діапазон місяців? Будь ласка! Днів тижнів чи часу? Дайте два! Хочеться взагалі супер гнучкості? Ніщо не заважає її додати! (Крім системи типів, звичайно).
Привабливість. Девʼять рядків дуже простого для читання коду. Так, для непосвячених він абсолютно незрозумілий. Але вчиться це один раз, зате потім зрозуміло завжди.
Є ще один гарний наочний приклад використання парсер-комбінаторів — послідовності. Це звичайна колекція елементів, розділених комою. Усі послідовності з прикладу:
Apr,Dec 08:00-12:00,13:00-17:30
Ще один абстрактний парсер-комбінатор:
Алгоритм роботи парсера послідовностей:
- Отримуємо парсер роздільника послідовностей. Взагалі це кома, але по специфікації може бути і крапка з комою в залежності від того, що за послідовність.
- Отримуємо парсер елементу послідовності.
- Отримуємо функцію-фабрику створення послідовності з її елементів.
Що цікавого тут є? SkipWhitespaces
означає пропустити пробіли. Нам вони непотрібні, але ми їх дозволяємо розставити скільки завгодно. Labelled
для того, щоб можлива помилка під час парсингу була зрозумілішою з вашим власним підписом парсеру. Просто зручно для дебагу.
Тут використаний цікавий трюк. Що таке взагалі послідовність? Скільки елементів може бути в послідовності? Взагалі, від нуля до скільки завгодно, але ж так працювати незручно. Натомість ми вважаємо, що послідовність для нас — це хоча б два елементи і більше. Як це забезпечити на рівні парсингу? Треба завжди парсити один елемент, а вже потім наступні, які розділені між собою роздільником. Таким способом і забезпечується умова валідності послідовності.
А далі в нас немає сенсу розписувати весь парсер. Але принцип ви зрозуміли. Елемент специфікації — парсер. Залишилося зібрати всі парсери докупи. Потрібно щось таке, що склеїть всіх разом. І це, звісно, буде ще один парсер. Пропускаючи купу проміжкових парсерів (які не покажуть нам нічого нового), переходимо до практично останнього — парсер правила.
Згадаємо наш тестовий вираз:
Apr,Jun-Aug,Dec 10:00-15:00; Mo-We,Fr 08:00-12:00,13:00-17:30; Sa 08:00-12:00; PH 09:00-12:00
Тут чотири правила:
Apr,Jun-Aug,Dec 10:00-15:00
Mo-We,Fr 08:00-12:00,13:00-17:30
Sa 08:00-12:00
PH 09:00-12:00
Парсер правила відповідає за кожне з вище перелічених правил. Не буду навантажувати тим, що за внутрішні парсери використовуються; нас цікавить, що таке Try
та Or
.
Or
— не вдалося розпарсити першим парсером? Не страшно, спробуємо наступним! Іншими словами, парсимо першим парсером або другим.
Try
— пам’ятаєте, як я казав: якщо не вдалося щось розпарсити, то можна спробувати ще раз? Саме для таких випадків і призначений Try
. Парсер працює посимвольно, Try
всього лише повертає позицію парсинга на той символ, з якого ми починали парсити невалідний вираз. Це означає, що можна спробувати розпарсити вираз ще раз, це якраз чудово працює в комбінації з Or
.
Фінальний парсер для всього виразу:
Дерево синтаксичних елементів для тестового виразу (трохи спрощене для зручності):
Ми буквально побудували дерево парсерів знизу вверх, комбінуючи прості елементи в складніші. Сам виклик парсеру виглядає ось так:
Лаконічно. Єдине що — незрозуміло, це що за тип зрештою виходить?
Result<TimeDomain>
якраз інкапсулює в собі два можливих результати парсингу: успішний, де повертаються сформовані нами структуровані дані, та неуспішний, де повертається інформація про помилку. Залишилось тільки сказати декілька слів про обчислення тимчасових умов.
Обчислення
Нам треба перевірити, чи належить заданий момент часу заданому виразу, чи ні. Кожен елемент виразу в нас називається Selector
, єдине, що їх об’єднує, — це умова обчислення критерію того, чи підходить заданий момент часу, чи ні. Ось і виразимо це через інтерфейс:
Як тоді буде виглядати якийсь селектор? Розглянемо на прикладі діапазону часу (нагадаю приклад
Це дуже спрощений клас: мета в тому, аби показати лише метод IsSatisfiedBy
. Отже, по суті, ми просто порівнюємо чи перебуває заданий час, який передали в функцію, між початком та кінцем діапазону. Для днів тижня та місяців вигляд аналогічний. Цікавим є узагальнений селектор діапазону:
Спрощено для стислості. Звісно, ми пишемо все як імутабельні класи!
Цікавим тут є узагальнення того, за чим нам треба порівнювати елементи. Вони загальні, що між ними спільного? Можна це переформулювати як і питання — що їх відрізняє один від одного? Ідентифікатор, причому за цим ідентифікатором можна і порівнювати елементи. Наприклад, для днів тижнів селектор діапазонів виглядає ось так:
Ідентифікатор — номер дня тижня. Можна за цим ідентифікатором задати відношення порядку, чітко сказати, який день тижня більший чи менший за інший. Зручно, просто, ефективно.
Як буде виглядати селектор для послідовності? Ось його основна частинка:
Нам достатньо того, що хоч якийсь елемент послідовності задовольняє умові відповідності заданому моменту часу. Логіка для обчислення послідовності правил доволі хитра, оскільки правила перекривають один одного, сенсу вже так напружувати мозок під кінець статі не дуже багато. Там уже нема магії дерев, нудна ітерація по колекції правил і обчислення поточного стану обмеження.
Тестування
Неймовірно зручно та лаконічно виходить тестувати, якщо в структуровані дані додати можливість зворотного перетворення в неструктуровані дані. Таким способом пишуться дуже прості тести, які складаються буквально з трьох дій: розпарсити, перетворити структуровані дані в неструктуровані, порівняти оригінальні структуровані дані та отримані в результаті парсингу.
Тут чудово показує себе TDD — спочатку розписали всі тестові випадки, потім пишемо для них парсер. Як ідея на подумати: є ще варіант тестувати парсери за допомогою property tests
.
Висновки
Інколи регулярних виразів просто недостатньо, а складні задачі валяються на дорозі. Парсер-комбінатори — ефективна та зручна на практиці техніка парсингу складних неструктурованих даних.
Практичні поради
— Парсер будується один раз, використовується багато разів.
— Піклуєтеся про перформанс? Міряйте — мені, наприклад, достатньо, а вам? Відповідь можна отримати тільки практичними замірами.
— Є різні варіанти, як можна розпарсити навіть найпростіше значення:
• обирайте найпростіший з точки зору readability та supportability;
• це зазвичай LINQ Query syntax;
• але пам’ятайте про свої вимоги щодо перформансу.
26 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів