Pull to refresh

(Spring) State in the (Spring) Shell: не продакшном единым

Reading time10 min
Views4.8K

Эй, как насчет интерактивной командной оболочки с автодополнением, помощью и прочим? И без заморочек да еще и на JVM?


Головной болью на работе для меня стал Postman. Хотя на словах мы все TDD и по красному огоньку Cucumber видим, что именно сломалось, но на практике приходилось мне гораздо чаще слать REST запросы в интерфейсе Postman. При начале работы надо было получить токен аутентификации (запрос на создание и запрос на валидацию, пользователи разные), а потом скакать по закладкам, править параметры и запускать уже другие запросы. Клик-клик-клик. В разном порядке. Уж я и скрипты с cURL писал, и в IDEA запросы оформлял — не удобно. Идеальный мотиватор для перехода на автоматические тесты, вот только это были запросы на получение понимания, что происходит в какой-то уникальный момент сочетания состояния сервиса, его версии, мейнфреймов за ним, погоды в доме и уж точно под регрессионное тестирование не попадали. Клик-клик-клик стал съедать слишком много времени и накручивал километраж мышки.


И тут на глаза мне попался проект Spring Shell, который запускает свой shell в консоли и выполняет команды, написанные в терминах Spring. Давно оценив преимущества командной строки, я сразу взялся за решение своей проблемы. Сказать, что результатом я остался доволен — это преуменьшение. Под катом — абстрактный проект для демонстрации возможностей shell, который навеян моим опытом. Чтобы сделать совсем красиво, я добавил плюшек с еще одним малоизвестным проектом — Spring State Machine. Может показаться, что конечные автоматы — это для седых профессоров, но реальность такова — на них, например, написаны корутины в Kotlin, а всякие Akka их несут в "массы" еще дольше. Я коснусь State Machine совсем поверхностно, только чтобы разогреть аппетит.


Настройка среды


Первым делом я пошел на Spring Initializr и создал проект на Gradle с Kotlin. Никаких дополнительных зависимостей не добавлял.


curl 'https://start.spring.io/starter.zip?type=gradle-project&language=kotlin&bootVersion=2.2.6.RELEASE&baseDir=shell-state&groupId=me.votez.spring&artifactId=shell-state&name=shell-state&description=Demo%20project%20for%20Spring%20State%20Machine%20and%20Shell&packageName=me.votez.spring.shellstate&packaging=jar&javaVersion=1.8' --compressed --output shellstate.zip && unzip shellstate.zip

Время добавить зависимости от Spring Shell в gradle


    implementation("org.springframework.shell:spring-shell-starter:2.0.0.RELEASE")

Описание команд для оболочки


Время написать команды для оболочки (shell). Команды находятся в классе, помеченном как ShellComponent (их может быть много) и каждая команда представляет собой метод, помеченный ShellMethod с описанием, что команда делает. Если нужны параметры, то они помечаются ShellOption. Параметры можно адресовать позиционно или по имени, для них работает валидация Bean Validation. Например, в нижеследующем примере у меня один параметр является Enum и для него даже работает подсказка при использовании команды (помимо того, что я написал в help).


В данном примере я описал две команды — аутентификация и якобы опрос сервиса. В реальности команд будет больше — опрос разных сервисов, разных точек сервиса, проверка actuator и т.д…
(на всякие runBlocking не обращаем внимания — это для красного словца там)


package me.votez.spring.shellstate

import org.springframework.shell.standard.ShellComponent
import org.springframework.shell.standard.ShellMethod
import org.springframework.shell.standard.ShellOption
import kotlinx.coroutines.*
import org.springframework.shell.standard.ShellMethodAvailability
import java.util.*
import kotlin.random.Random

@ShellComponent
class ServerCommands {
    private var token: String? = null

    @ShellMethod("Authenticate and obtain token")
    fun login(
           @ShellOption name:String, 
           @ShellOption password:String, 
           @ShellOption(defaultValue = "admin") scope: String) = runBlocking {
            token = UUID.randomUUID().toString()
            delay(2_000)
            "Connected"
        }
    }

    @ShellMethod("List projects regstered on server")
    fun list(
        @ShellOption(defaultValue = "PROJECT", help = "Possible values are PROJECT and USER") type:EntityType) = when(type) {
        EntityType.PROJECT -> listOf("Roga i Kopita", "Svetliy Put", "NIICHAVO")
        EntityType.USER -> listOf( "Ivanov", "Petrov", "Sidorov")
    }
}

enum class EntityType {
    PROJECT,
    USER
}

Уже можно пробовать работу приложения. Не стоит запускать его в IntelliJ IDEA, так как Spring Shell работает с терминалом через библиотеку JLine, которая хорошо интегрируется в Linux, MacOS и Windows, а вот с IDE — нет.


$ bash ./gradlew build
$ java -jar build/libs/shell-state-0.0.1-SNAPSHOT.jar 

Прямо из коробки можно вызвать help, который выведет список доступных встроенных и ваших собственных команд. Также сразу встроенны clear, exit, quit, script, stacktrace.


Консоль
2020-05-03 12:49:35.442  INFO 23211 --- [           main] m.v.s.s.ShellStateApplicationKt          : Started ShellStateApplicationKt in 1.036 seconds (JVM running for 0.936)
shell:>help
AVAILABLE COMMANDS

Built-In Commands
        clear: Clear the shell screen.
        exit, quit: Exit the shell.
        help: Display help about available commands.
        script: Read and execute commands from a file.
        stacktrace: Display the full stacktrace of the last error.

Server Commands
        list: List projects registered on server
        login: Authenticate and obtain token

shell:>help list

NAME
        list - List projects registered on server

SYNOPSYS
        list [[--type] entity-type]  

OPTIONS
        --type  entity-type
                Possible values are PROJECT and USER
                [Optional, default = PROJECT]

Выполним команду. Обе login и list начинаются с L, так что можно порадоваться автодополнению. Нажав l и TAB можно увидеть доступные команды внизу экрана, а добавив o и нажав TAB, сразу получить login. Тот же фокус проходит и с параметрами. Мне подсказки особо не нужны, поэтому я делаю > login root 123456 и команда выполняется, выведя на экран результат — в моем случае Connected. То же и с list .


Управление доступностью команды


В моем случае вызов list без токена аутентификации не имеет смысла, так что я хочу запретить вызов list без токена. Вместо богомерзких проверок внутри команды эту функциональность можно вывести в отдельный метод:



    @ShellMethodAvailability("list")
    fun listAvailable() = if (token != null) Availability.available()  else Availability.unavailable("cannot run without auth token")

Команда все еще будет доступна в shell, в том числе автодополнение будет ее предлагать, но вызова ее производиться не будет. Команда help также даст подсказку, что вызов недоступен.


Вывод при попытке доступа
2020-05-02 13:30:12.593  INFO 4443 --- [           main] m.v.s.s.ShellStateApplicationKt          : Started ShellStateApplicationKt in 1.525 seconds (JVM running for 1.252)
shell:>list
Command 'list' exists but is not currently available because cannot run without auth token
Details of the error have been omitted. You can use the stacktrace command to print the full stacktrace.
shell:>help
AVAILABLE COMMANDS

Built-In Commands
        clear: Clear the shell screen.
        exit, quit: Exit the shell.
        help: Display help about available commands.
        script: Read and execute commands from a file.
        stacktrace: Display the full stacktrace of the last error.

Server Commands
      * list: List projects regstered on server
        login: Authenticate and obtain token

Commands marked with (*) are currently unavailable.
Type `help <command>` to learn more.

shell:>help list

NAME
        list - List projects registered on server

SYNOPSYS
        list [[--type] entity-type]  

OPTIONS
        --type  entity-type
                Possible values are PROJECT and USER
                [Optional, default = PROJECT]

CURRENTLY UNAVAILABLE
        This command is currently not available because cannot run without auth token.

Немного красоты


Есть несколько плюшек в терминале, которые облегчают жизнь. Например, можно выводить псевдосимволы и с ними делать индикатор прогресса или вертящийся индикатор, можно раскрашивать текст разными цветами, есть очень мощный интерфейс для вывода красивых табличек. Мне было полезно видеть в command prompt, с каким я окружением работаю сейчас и под какому пользователем токен выдан. С именем пользователя не покажу, а вот окружение недля примера вытащу из application.properties — чтобы было видно, что всякие штуки Spring Framework работают как надо (что и следовало ожидать). Плюс буду менять цвет prompt в зависимости от того, залогинен ли я.


Для настройки надо предоставить PrompProvider, который должен вернуть строку, понятную JLine.


@ConfigurationProperties(prefix="shell")
class ServerCommands : PromptProvider

    lateinit var env: String // придет из application.properties

    override fun getPrompt(): AttributedString =
            AttributedString("${env}:>",
                    AttributedStyle.DEFAULT.foreground(
                            if (token == null) AttributedStyle.YELLOW else AttributedStyle.GREEN))

Теперь текущее состояние прямо перед глазами, да еще и цветное. Удобно.


Написать побольше команд, написать действительно работающий с сервисами код именно в том виде, в котором нужно проекту (soap, protobuf или еще чего) — и под рукой удобный инструмент, позволяющий выполнять последовательности проект-специфичных действий быстро. Да, скрипты тоже можно оттуда же запускать.


И о Spring State Machine


На самом деле тут автомат не нужен, но уж больно хочется. Автоматы вообще редко приходит в голову использовать. Мне кажется, что для них существует отдельный котел, не рядом с обычным, ынтырпрайзовым или с веб. Но вот смотрю на фреймворки акторов, а ведь они продвигают идею автоматов и еще в ынтырпрайз лезут… Не все так однозначно, как говорят.


Мои пять копеек в рассуждение об автоматах в контексте Spring State Machine.


Автоматы хорошо вписываются в модель, когда у нас есть поток событий (действий), но одинаковые события в разные моменты должны обрабатываться по-разному. Пример — отличная игра The Evolution of Trust. Можно обманывать оппонента, но тот меняет стратегию (состояние) и начинает отвечать иначе на те же события. Мне встречалась задача преобразования токенов xml в вычисления — те же токены в разных контекстах нуждались в разной обработке, а на стеке это не решалось из-за некоторых особенностей языка описания.


Неплохо вписываются, когда изменение поведения сложно описать. Например, можно "проваливаться" сквозь несколько состояний или когда нужно обработать событие в новом состоянии попозже. Это все можно описать и без Spring State Machine и иногда это просто, но бывает, что вложение времени в автомат себя окупает.


Плохо работает, когда мы скармливаем события и хотим сразу ответ на них. Автомат в принципе больше про себя, а не про пользователя. Это работает — Spring SM синхронный и в контекст можно запихивать ответ, но это неканоническое использование и его не стоит поощрять. Лучше всего схема "выстрелил и забыл", а автомат может что-то строить внутри, кого-то оповещать о переходе в специфические состояния, выкатывать задания, но непосрественно отвечать на каждое событие — это не про то.


Ну, это мое личное мнение, основанное на личном опыте. Если кто-то запускал спутники или пылесосы на автоматах и имеет другое мнение — милости прошу поделиться, а не спорить.


Так куда его засунуть в моем примере?


Хочу вот такое, простое.

В реалиях авторизация запускалась в корутине и ответ приходил асинхронно, порождая событие "Авторизован" в фоне. В примере я так сложно не буду. В настоящем приложении так же был периодическое событие (из коробки поддерживается) на обновление токена, но это запускает внутри Thread Pool, который надо искать и гасить руками при выходе из shell, так как SSM это почему-то забывает сделать при остановке автомата — это лишнее в рамках примера.


Также я привязал состояние command prompt к состоянию автомата и "контексту автомата", а не к внутренним переменным.


Я опущу описание подключения артефакта и простыню настройки автомата, оставив лишь самые важные моменты. Пропущенные части элементарно выхватываются из Quick Start.


Итак, моя винтовка мой автомат будет выполнять глупый минимум действий при переходе в состояния — только для иллюстрации. Сам автомат будет использована как молоток для забивания гвоздей — для проверки доступности команды и для раскраски command prompt. Микроскоп или нет, но у нас есть согласованное состояние, которому можно доверять и которое не зависит от рефакторинга работы с моими переменными — например, token. Его теперь можно сделать null-safe и lateinit.
Настрою автомат.


override fun configure(transitions: StateMachineTransitionConfigurer<State, Event>) {
        transitions
                .withExternal()
                .source(State.INIT).target(State.CONNECTING)
                .event(Event.LOGIN)
                .and()
                .withExternal()
                .source(State.CONNECTING).target(State.READY).event(Event.CONNECTED)
                .action { context -> context.extendedState.variables["token"] = context.messageHeaders["token"] }.and()
                .withExternal()
                .source(State.CONNECTING).target(State.INIT).event(Event.FAILED).and()
                .withInternal()
                .source(State.READY).event(Event.COMMAND).and()
                .withExternal()
                .source(State.READY).target(State.CONNECTING).event(Event.LOGIN)
    }

....
enum class State {    INIT, CONNECTING, READY }
enum class Event {  LOGIN, CONNECTED, FAILED, COMMAND }

Из нагрузки — единственное действие, которое делает автомат — это запоминание токена. В реальности действий может быть больше и они могут быть полноценными компонентами Spring. Например, при достижении конечного автомата можно вывести статистику времени выполнения, накопленную при переходах. Или разлогиниться.


Теперь можно пробросить события от команд в автомат. Посмотрим на новый login:


    @Autowired
    lateinit var stateMachine: StateMachine<State, Event>

   // Гораздо лучше без null
    lateinit var token: String

    @ShellMethod("Authenticate and obtain token")
    fun login(
            @ShellOption name: String,
            @ShellOption password: String,
            @ShellOption(defaultValue = "admin") scope: String) =
            runBlocking {
                stateMachine.sendEvent(
                        MessageBuilder.createMessage(Event.LOGIN, MessageHeaders(mapOf("login" to name))))
                delay(1_000)
                token = UUID.randomUUID().toString()
                stateMachine.sendEvent(
                        MessageBuilder.createMessage(Event.CONNECTED, MessageHeaders(mapOf("token" to token))))
                "Connected"
            }
...

И опишем новый command prompt:


    private val colors = mapOf(State.READY to AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN))
            .withDefault { AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW) }

    override fun getPrompt(): AttributedString =
            AttributedString("${env}:>", colors.getValue(stateMachine.state.id))

И проверку доступности команд:


    @ShellMethodAvailability("list")
    fun listAvailable() =
            if (stateMachine.state.id == State.READY) Availability.available()
            else Availability.unavailable("requires authentication performed first")

У Spring State Machine много возможностей — проверка состояний при переходах (guards), сохранение в хранилище, распределенные автоматы, очереди отложенных событий, подписка на события от автомата и прочее, и прочее.


Заключение


Spring Shell — отличное решение для специфических утилит под разработку проектов, когда есть потребность в частичной автоматизации нетривиальных задач.


Spring State Machine — еще один из множества проектов сообщества, который заслуживает большего внимания и является актуальной технологией, несмотря на предрассудки.


Код примера доступен на GitHub .


Tags:
Hubs:
+5
Comments5

Articles