Стейт-машини. Частина 3: Ієрархічі стейт-машини.

Частина 1
Частина 2
Частина 3

Навіщо нам в програмуванні абстракції? Коли настав той момент в історії, коли інженери зрозуміли, що неможливо створювати складні програми лише пишучи послідовності простих інструкцій? Я думаю, це почалось практично одразу: перший же програміст в історії людства, Августа Ада Кінґ, графиня Лавлейс, ввела у вжиток термін “цикл”. З того часу, чим більше коду з’являлось на планеті, тим сильніше цей код обростав різноманітними абстракціями. У ньому з’являлись функції, дані об’єднувались у структури, структури обростали функціями та поліморфізмом, утворюючи класи. Паралельно з цим з’являвся світ функціонального програмування із функціями першого класу, замиканнями та монадами. Сучасне програмування продовжує цю тенденцію, адже нам треба більше абстракцій! У 2011 році лямбда-вирази з’явились навіть у C++ (стандарт C++11), а народжені 2010x роках мови на кшталт Swift чи Kotlin вже із самого початку існування були мультипарадигмовими, себто містили в собі одразу декілька наборів абстракцій.

Очевидно, у софтверних інженерів є значний попит на абстракції. І це не через те, що ми, програмісти, любимо відокремитись від християнського світу, жонглюючи різними термінами, жаргонізмами та англіцизмами. Ми звісно любимо то робити, брехать не буду, але абстракції нам потрібні не для того, щоб менше мати спільного з іншими людьми. Вони нам потрібні як раз тому, що ми самі є людьми, і, відповідно, маємо людський мозок. А цей досить дивний агрегат. З одного боку, у нього вбудовані такі потужні версії Tensor Flow та CoreML, що й Крісу Латнеру не снилось. І разом з тим, він досить примітивний у речах, що є простими для комп’ютерів. Комп’ютерові за нема шо робить виконувати функції з 20-ма аргументами й 1000-рядковими реалізаціями. Та нашому мозку такі витвори техномистецтва чомусь не подобаються, ми їх вважаємо ознаками профнепридатності автора. Адже наш бідний мозок має дуже скромний ліміт на кількість об’єктів, якими він може оперувати одночасно. Цих об’єктів небагато, десь 3-4, максимум 7, якщо ви добре знайомі із задачею, ну або якщо ви прям геній.

Люди облаштували свій соціум відповідно до цього обмеження. Будь-яка організація людей має ієрархічну структуру. Наприклад, немає армії, де є лише один генерал і десять тисяч рядових. Чому? Тому що, аби роздати накази кожному Йозефу Швейку напряму, не вистачить ані часу, ані місця в голові бідолашного генерала, і останньому ніяк не можна дати ради. Всякий стартап, що проходить шлях від двох жевжеків у гаражі до виходу на IPO, неминуче обростає пірамідою менеджерів, котра надійно оберігає більшість працівників від прямого спілкування з CEO. Адже навіть найкращий управлінець має ліміт у 3-4 об’єкти, котрими його мозок може оперувати одночасно, тому й кількість ефективно підпорядкованих йому людей є такою ж невеликою.

Цей ліміт не зникає, коли ми починаємо займатись програмуванням. Він змушує нас розбивати великі функції та методи на дрібні, навіть якщо мова не йде про повторне використання. Він змушує створювати рівні абстракції та розподіляти код поміж ними, адже нам складно працювати з мережею, базою даних, інтерфейсом користувача і бізнес-логікою одночасно. Саме це обмеження нашого мозку продукує цей попит на абстракції.

Складність у стейт-машинах

Стейт-машини тут не виключення. Вони дозволяють описувати складну логіку лінійно і просто, але чим більше станів та переходів у стейт-машині – там більше стає коду, що її описує, тим складніше стає цей код усвідомити, і в певний момент простота і лінійність цього коду перестає працювати. Тобто вона не перестає працювати в коді, але вона перестає працювати у нас в мозку. Не кажучи вже про неконтрольований ріст кількості юніт-тестів, з яким ми боролись у попередньому дописі.

Сьогодні ми побудуємо ще одну стейт-машину. Може, дехто з вас уже втомився від стейт-машин, але мене вже не спинити, тому кріпіться. Аби це не здавалось нам пустою забавкою, ми зробимо якусь корисну стейт-машину. Зокрема, ми реалізуємо роботу з WebSocket за допомогою стейт-машини.

На відміну від звичних усім iOS-інженерам HTTPS-запитів, робота з веб-сокетом вимагає складного керування станом. Перш за все, у будь-який момент часу WebSocket є або з’єднаним, або роз’єднаним. Або в стані з’єднування. Або в стані роз’єднування. І в будь-якому стані той, хто керує веб-сокетом, повинен реагувати на певні події, як то:

  • сокет з’єднався
  • сокет роз’єднався

WebSocket не готовий до надсилання повідомлень у будь-який момент часу: повідомлення можна відправити лише в момент, коли з’єднання встановлене. В різні моменти часу веб-сокет поводить себе по різному, в залежності від поточного стану. Тому використання стейт-машини є природним способом роботи з ним.

graph TB; Disconnected -- connect --> Connecting; Connecting -- connected --> Connected; Connected -- disconnect --> Disconnecting; Disconnecting -- disconnected --> Disconnected;

Уявимо собі, що наш протокол вимагає, аби клієнт після з’єднання переслав якесь початкове повідомлення, наприклад, StartSession. Це означає, що ми не можемо почати працювати з веб-сокетом, допоки не буде переслане це повідомлення, що додає в нашу діаграму додатковий стан:

graph TB; Disconnected -- connect --> Connecting; Connecting -- connected --> StartingSession; StartingSession -- startedSesssion --> Connected; Connected -- disconnect --> Disconnecting; Disconnecting -- disconnected --> Disconnected;

З’єднання не завжди буде встановлюватись успішно, то ж слід це теж врахувати. В одних випадках це буде через відсутність зв’язку, в інших – через помилки автентифікації:

graph TB; Disconnected -- connect --> Connecting; Connecting -- connected --> StartingSession; Connecting -- connectionFailed --> Disconnected; Connecting -- authFailed --> Disconnected; StartingSession -- startedSesssion --> Connected; StartingSession -- connectionFailed --> Disconnected; StartingSession -- authFailed --> Disconnected; Connected -- disconnect --> Disconnecting; Connected -- connectionFailed --> Disconnected; Connected -- authFailed --> Disconnected; Disconnecting -- disconnected --> Disconnected;

Уявімо собі, що нам потрібна поведінка автоматичного повторного під’єднання, яка полягає в наступному. Якщо зв’язок було втрачено, то ми пробуємо його відновити. Якщо нам це не вдається, ми чекаємо якийсь час і пробуємо ще, допоки користувач не скасує цю вакханалію явно.

Для такої поведінки нам знадобиться два нових стани: Reconnecting та Waiting. У стані Reconnecting ми пробуємо відновити з’єднання, у Waiting – чекаємо до наступної спроби.

graph TB; Disconnected -- connect --> Connecting; Connecting -- connected --> StartingSession; Connecting -- connectionFailed --> Reconnecting; Connecting -- authFailed --> Disconnected; StartingSession -- startedSesssion --> Connected; StartingSession -- connectionFailed --> Reconnecting; StartingSession -- authFailed --> Disconnected; Connected -- disconnect --> Disconnecting; Connected -- connectionFailed --> Reconnecting; Connected -- authFailed --> Disconnected; Reconnecting -- connected --> StartingSession; Reconnecting -- connectionFailed --> Waiting; Reconnecting -- disconnect --> Disconnecting; Waiting -- timerFired --> Reconnecting; Waiting -- disconnect --> Disconnecting; Disconnecting -- disconnected --> Disconnected;

Тут нам допомогло виконане раніше розрізнення подій authFailed та connectionFailed: адже при реалізації логіки повторного з’єднання на них слід реагувати по-різному.

Якщо схема вище вже почала нагадувати вам спагеті – ми на правильному шляху, тому йдемо далі. Уявімо собі, що нам став потрібен “сплячий” стан: стан, в якому не відбувається ніякої комунікації з сервером, але з’єднання залишається активним. Наприклад, якщо наше з’єднання використовується для онлайн-гри, і нам потрібно реалізувати в ній паузу. Ми хочемо продовжити гру, як тільки користувач натисне “Продовжити”, і тому не хочемо глушити двигун нашого вебсокета. Однак і обробляти вхідні повідомлення з нього нам також не потрібно. Для цього ми введемо стан Suspended.

graph TB; Disconnected -- connect --> Connecting; Connecting -- connected --> StartingSession; Connecting -- connectionFailed --> Reconnecting; Connecting -- authFailed --> Disconnected; StartingSession -- startedSesssion --> Connected; StartingSession -- connectionFailed --> Reconnecting; StartingSession -- authFailed --> Disconnecting; StartingSession -- suspend --> Suspended; Connected -- disconnect --> Disconnecting; Connected -- connectionFailed --> Reconnecting; Connected -- authFailed --> Disconnecting; Connected -- suspend --> Suspended; Reconnecting -- connected --> StartingSession; Reconnecting -- connectionFailed --> Waiting; Reconnecting -- disconnect --> Disconnecting; Waiting -- timerFired --> Reconnecting; Waiting -- disconnect --> Disconnecting; Suspended -- resume --> StartingSession; Suspended -- disconnect --> Disconnecting; Suspended -- authFailed --> Disconnecting; Disconnecting -- disconnected --> Disconnected;

Однак, тепер виникають питання. Як реагувати на подію suspend, коли ми пробуємо відновити втрачене з’єднання? А що, коли з’єднання обірветься, коли стейт-машина знаходиться у сплячому стані Suspended? Ми, звісно, можемо перейти в стан Reconnecting, але він при успішному відновленні з’єднання веде до StartingSession, а нам треба повернутись у Suspended. Можна створити пару злих братів-двійників для станів Reconnecting та Waiting: SuspendedReconnecting та SuspendedWaiting. Вони працюватимуть так само, але у випадку успішного з’єднання відбуватиметься перехід зі стану SuspendedReconnecting в стан Suspended, замість StartingSession.

graph TB; Disconnected -- connect --> Connecting; Connecting -- connected --> StartingSession; Connecting -- connectionFailed --> Reconnecting; Connecting -- authFailed --> Disconnected; StartingSession -- startedSesssion --> Connected; StartingSession -- connectionFailed --> Reconnecting; StartingSession -- authFailed --> Disconnecting; StartingSession -- suspend --> Suspended; Connected -- disconnect --> Disconnecting; Connected -- connectionFailed --> Reconnecting; Connected -- authFailed --> Disconnecting; Connected -- suspend --> Suspended; Reconnecting -- connected --> StartingSession; Reconnecting -- connectionFailed --> Waiting; Reconnecting -- disconnect --> Disconnecting; Waiting -- timerFired --> Reconnecting; Waiting -- disconnect --> Disconnecting; Suspended -- resume --> StartingSession; Suspended -- disconnect --> Disconnecting; Suspended -- authFailed --> Disconnecting; Suspended -- connectionFailed --> SuspendedReconnecting; SuspendedReconnecting -- connected --> Suspended; SuspendedReconnecting -- connectionFailed --> SuspendedWaiting; SuspendedReconnecting -- disconnect --> Disconnecting; SuspendedWaiting -- timerFired --> SuspendedReconnecting; SuspendedWaiting -- disconnect --> Disconnecting; Disconnecting -- disconnected --> Disconnected;

Ну що, як вам цей величний витвір інженерного мистецтва? Мені особисто він нагадує Пізанську вежу: по-перше, він не менш виточений, а по-друге, вдячні нащадки витратять багато зусиль і годиногрошей на її розширення та багфікси, а головне – на те, щоб зрозуміти, чого ж воно, зараза, таке криве і весь час падає?

Пора насипати трохи абстракцій

Якщо ви уважно слідкували за рухами моїх рук при побудові цієї стейт-машини, то ви мабуть, встановили вебкамеру над моєю клавіатурою могли помітити, що деяка частина логіки у ній повторюється, зокрема логіка повторного підключення, Reconnecting та SuspendedReconnecting. А що ми, програмісти, робимо із повторюваним кодом? Правильно, виносимо його в окрему сутність, в даній ситуації – в окрему стейт-машину:

graph TB; Reconnecting -- connectionFailed --> Waiting; Waiting -- timerFired --> Reconnecting;

Ця стейт-машина проста для розуміння й тестування. Тепер спробуємо використати її для того, щоб спростити монстра, котрого ми збудували раніше. Для цього ми озброїмось новою абстракцією: ієрархічною стейт-машиною. Ідея проста: уявімо, що один зі станів стейт-машини сам по собі є стейт-машиною. Іншими словами, раз ми вже третій допис поспіль будуємо стейт-машини, настав час покласти стейт-машину всередину стейт-машини, щоб вона могла змінювати стан доки вона змінює стан.

yo dawg I herd you like state machines so we put state machine into your state machine so it can process events while it processes events

graph TB; Disconnected -- connect --> Connecting; Connecting -- connected --> StartingSession; Connecting -- connectionFailed --> Reconnecting; Connecting -- authFailed --> Disconnected; StartingSession -- startedSession --> Connected; StartingSession -- connectionFailed --> Reconnecting; StartingSession -- authFailed --> Disconnecting; StartingSession -- suspend --> Suspended; Connected -- disconnect --> Disconnecting; Connected -- connectionFailed --> Reconnecting; Connected -- authFailed --> Disconnecting; Connected -- suspend --> Suspended; Reconnecting([Reconnecting]); Suspended([Suspended]); Reconnecting -- connected --> StartingSession; Reconnecting -- disconnect --> Disconnecting; Suspended -- resume --> StartingSession; Suspended -- disconnect --> Disconnecting; Suspended -- authFailed --> Disconnecting; Disconnecting -- disconnected --> Disconnected; classDef childStateMachine fill:#f9f,stroke:#333,stroke-width:4px; class Reconnecting childStateMachine; class Suspended childStateMachine;

Аналогічним чином ми винесемо стан Suspended в окрему стейт-машину, котра реалізовуватиметься за допомогою вже готової стейт-машини Reconnecting:

graph TB; Reconnecting([Reconnecting]); Suspended -- disconnected --> Reconnecting; Reconnecting -- connected --> Suspended; classDef childStateMachine fill:#f9f,stroke:#333,stroke-width:4px; class Reconnecting childStateMachine;

Це сильно спрощує як логіку повторного з’єднання та логіку призупинення роботи стейт-машини, так і всю стейт-машину нашого вебсокета. Фактично ми побудували наступну ієрархію:

graph BT; Socket[Socket SM]; Reconnecting1[Reconnecting SM]; Reconnecting2[Reconnecting SM]; Suspended[Suspended SM]; Reconnecting1 --> Socket; Suspended --> Socket; Reconnecting2 --> Suspended;

 ## Трошки покодимо

Спробуємо реалізувати описану вище ієрархію стейт-машин на Swift, адже ми саме для цього сюди прийшли, чи не так?

Головним нюансом реалізації ієрархічних стейт-машин, на який слід звернути увагу на початку, є той факт, що всі стейт-машини в ієрархії повинні мати на рівні інтерфейсу один і той же список подій. Звісно, різні стейт-машини в ієрархії описують різну логіку, і тому кожна з них має окремий список подій. Однак таке об’єднання сильно спрощує реалізацію.

Слід зазначити, що як звичайні, так і ієрархічні стейт-машини можна реалізовувати по-різному. Можна моделювати кожен зі станів за допомогою нащадку якогось базового класу, як в це робиться в GKStateMachine. Можна реалізовувати стани та події у вигляді перечислень, як ми це робили у попередніх серіях. Кожен зі способів має свої плюси та мінуси, тож в конкретній ситуації слід обирати керуючись обставинами, ну або власним смаком. Ми ж продовжимо нашу традицію реалізовувати стани та події за допомогою перечислень (enum).

Спочатку визначимо стани та події основної стейт-машини:

class SocketStateMachine {
    enum State: Equatable {
        case disconnected
        case connecting
        case startingSession
        case connected
        case reconnecting(ReconnectingStateMachine)
        case suspended(SuspendedStateMachine)
        case disconnecting
    }

    enum Event: Equatable {
        case connect
        case connected
        case connectionFailed
        case authFailed
        case startedSession
        case suspend
        case resume
        case disconnect
        case disconnected
        case reconnectTimerFired
    }

    private(set) var state: State = .disconnected

    ...
}

Стани, котрі самі є стейт-машинами, виражаються за допомогою асоційованих значень елементів перечислень case reconnecting(ReconnectingStateMachine) та case suspended(SuspendedStateMachine).

Тепер питання полягає у тому, як інтегрувати переходи у внутрішніх стейт-машинах із переходами зовнішньої стейт-машини. Ідея така: внутрішні стейт-машини не змінюватимуть свій внутрішній стан, а повертатимуть новий стан для зовнішньої стейт-машини. Зокрема, стейт-машина повторного з’єднання матиме наступний вигляд:

extension SocketStateMachine {
    struct ReconnectingStateMachine: Equatable {
        enum State: Equatable {
            case reconnecting
            case waiting
        }
        var state: State

        func transition(with event: Event) -> SocketStateMachine.State {
            switch (state, event) {
            case (.reconnecting, .connectionFailed):
                return .reconnecting(ReconnectingStateMachine(state: .waiting))
            case (.reconnecting, .connected):
                return .startingSession
            case (.reconnecting, .disconnect):
                return .disconnecting
            case (.reconnecting, _):
                return .reconnecting(self)

            case (.waiting, .reconnectTimerFired):
                return .reconnecting(ReconnectingStateMachine(state: .reconnecting))
            case (.waiting, .connected):
                return .startingSession
            case (.waiting, .disconnect):
                return .disconnecting
            case (.waiting, _):
                return .reconnecting(self)
            }
        }
    }
}

Фактично, ReconnectingStateMachine бере на себе відповідальність щодо обробки лише тих подій, котрі якось впливають на логіку повторного з’єднання.

Тепер ми спробуємо аналогічно реалізувати стейт-машину для паузи:

extension SocketStateMachine {
    struct SuspendedStateMachine: Equatable {
        enum State: Equatable {
            case suspended
            case reconnecting(ReconnectingStateMachine)
        }
        var state: State

        func transition(with event: Event) -> SocketStateMachine.State {
            switch (state, event) {
            case (.suspended, .resume):
                return .startingSession
            case (.suspended, .disconnect),
                 (.suspended, .authFailed):
                return .disconnecting
            case (.suspended, .connectionFailed):
                return .suspended(
                    SuspendedStateMachine(
                        state: .reconnecting(ReconnectingStateMachine(state: .reconnecting))
                    )
                )
            case (.suspended, _):
                return .suspended(self)

            case let (.reconnecting(reconnectingSM), _):
                let newState = reconnectingSM.transition(with: event)
                switch newState {
                case let .reconnecting(stateMachine):
                    return .suspended(SuspendedStateMachine(state: .reconnecting(stateMachine)))
                default:
                    return newState
                }
            }
        }
    }
}

Тут SocketStateMachine не копіює логіку повторного з’єднання, а делегує його дочірній ReconnectingStateMachine. Це ж забезпечує повернення зі стану Reconnecting саме в стан Suspended, а не в стан startingSession: ми примушуємо логіку ReconnectingStateMachine працювати в іншому контексті.

Тепер лишається реалізувати логіку основної стейт-машини:

class SocketStateMachine {
    func transition(with event: Event) {
        switch (state, event) {
        case (.disconnected, .connect):
            state = .connecting
        case (.disconnected, _):
            break

        case (.connecting, .connected):
            state = .startingSession
        case (.connecting, .connectionFailed):
            state = .reconnecting(ReconnectingStateMachine(state: .reconnecting))
        case (.connecting, .authFailed):
            state = .disconnecting
        case (.connecting, _):
            break

        case (.startingSession, .startedSession):
            state = .connected
        case (.startingSession, .connectionFailed):
            state = .reconnecting(ReconnectingStateMachine(state: .reconnecting))
        case (.startingSession, .authFailed):
            state = .disconnecting
        case (.startingSession, .suspend):
            state = .suspended(SuspendedStateMachine(state: .suspended))
        case (.startingSession, _):
            break

        case (.connected, .disconnect):
            state = .disconnecting
        case (.connected, .connectionFailed):
            state = .reconnecting(ReconnectingStateMachine(state: .reconnecting))
        case (.connected, .authFailed):
            state = .disconnecting
        case (.connected, .suspend):
            state = .suspended(SuspendedStateMachine(state: .suspended))
        case (.connected, _):
            break

        case (.disconnecting, .disconnected):
            state = .disconnected
        case (.disconnecting, _):
            break

        case let (.suspended(suspendedStateMachine), _):
            state = suspendedStateMachine.transition(with: event)

        case let (.reconnecting(reconnectingStateMachine), _):
            state = reconnectingStateMachine.transition(with: event)
        }
    }
}

Тут логіка простих станів реалізовується на місці, а обробка повторних з’єднань та паузи делегується відповідним дочірнім стейт-машинам.

Слід звернути увагу, що реалізація кожної зі стейт-машин більш-менш влазить в один екран коду. При цьому складність цієї логіки відповідає найбільш заплутаній діаграмі в цьому дописі. При цьому, шляхом розбиття стейт-машини на ієрархічні складові ми можемо дуже сильно скоротити кількість тестів. У попередньому дописі ми встановили, що кількість тестових випадків стейт-машини – це кількість подій перемножити на кількість станів. Кількість же тест-кейсів для ієрархічної стейт-машини – це сума кількості кейсів кожної з її складових стейт-машин. Перехід від множення до суми дозволяє краще стримувати сумарну кількість тестів, котрими слід покривати це щастя.

Кінець трилогії

tatooine.jpg

Ми розпочали цикл дописів про стейт-машини з того, що навчились спрощувати наші абстракції за допомогою стейт-машин. Сьогодні ми навчились спрощувати наші стейт-машини за допомогою абстракцій. Зі смутком повідомляю, що наша трисерійна подорож світом скінченних автоматів на цьому скінчилася, але я маю нову надію на те, що це буде лише початком нової подорожі, з кращими спецефектами та новими авторами.

Якщо ви любите iOS, macOS, чи інший OS де абстракції з вашої голови втілюються у вигляді елегантного свіфтового коду, то рано чи пізно вам захочеться поділитись цим з іншими. І немає кращого майданчика для цього, аніж рідний swift.org.ua. Адже ми тут говоримо про Swift. Українською.