Использование Spring Groovy-контекста для создания конфигурируемого, интерактивного графического UI

от автора

В конце 90-х годов работал в одной организации, которая занималась развитием контактной электрической сети и эксплуатацией питающих энергетических установок (тяговых подстанций), плюс осуществляла мониторинг и управление этого хозяйства с помощью специализированного программно-аппаратного комплекса из нескольких диспетчерских пунктов. Комплекс работал под управлением АСУ ТП на древнем советском железе. Тогда стояла задача перевести этот комплекс под windows, включая разработку графического интерфейса, который бы отображал схематически в виде карты всю сеть и события происходящие на ней и подстанциях. Ну и естественно, предоставлял бы возможность управлять ими.

Описание задачи.

Технически задача заключалась в том, чтобы по сети (IP) получить данные (пакет-сообщение), распарсить его (получить данные о подстанции и изменениях на ней) и в итоге отобразить все это в нашем UI клиенте (отобразить изменения на карте/схеме, например поменять цвет элемента и заставить его мигать). Каждая подстанция обладала определенным набором датчиков и счетчиков, данные которых и содержались в пакетах-сообщениях. Необходимо было т. ж. реализовать возможность выбора на карте подстанции и отобразить ее в схематическом виде с интерактивными управляющими элементами, с помощью которых оператор мог бы посылать команды.

Реализация.

Как вы понимаете графические элементы были довольно специфичные и соответственно использовать стандартные не представлялось возможным, кроме того по сути надо было реализовать анимацию. И тогда было выбрано решение с использованием double buffering, когда при изменениях прорисовывался соответствующий кадр и происходила их смена с определенной частотой. Первая реализация была выполнена на Delphi. Но в процессе эксплуатации мы столкнулись с тем, что часто было необходимо менять отображение конфигурации оборудования и сети. Что в свою очередь каждый раз требовало вмешательство в исходный код (писать тогда какой либо конфигуратор на Delphi было лениво) да и как то не очень красиво выходило). И в 1999 году я первый раз познакомился с Java. Меня зацепила тогда технология reflection с помощью которой был создан простой скриптовый язык (похожий на Groovy, я тогда про него правда и не слышал, да и не мог, т.к. Groovy появился в 2003)) для описания схемы. Он так же как и Groovy использует объекты Java.
Ниже приведен фрагмент скрипта где Tp, Bus, Lab, Led, Box не что иное как Java-классы обёртки над графическими примитивами позволяющие реализовать описанный выше в задаче функционал.
Root.txt

setFormFactor:4 4 setResolution:700 800 setScale:0.45 setScaleStep:0.1  define:"ТУ" "contr.txt" define:"Ввод1" "src/1.txt" define:"Ввод2" "src/2.txt" define:"Ввод3" "src/3.txt" define:"АГР1" "dev/1.txt" define:"АГР2" "dev/2.txt" define:"АГР3" "dev/3.txt" define:"АГР4" "dev/4.txt" define:"ЗВ" "sw/0.txt" define:"ЛВ1" "sw/1.txt" define:"ЛВ2" "sw/2.txt" define:"ЛВ3" "sw/3.txt" define:"ЛВ4" "sw/4.txt" define:"ЛВ5" "sw/5.txt" define:"ЛВ6" "sw/6.txt"  add:new Tp 31 {   busWidth:7   setColor:col.red col.green   add:new Bus 4 0 0 2 523 106 627 160   add:new Bus 5 0 0 2 636 164 683 190   add:new Bus 6 0 0 2 689 195 741 243   add:new Bus 29 0 0 2 730 268 650 268   setColor:col.black col   add:new Led "" 627 157 8   add:new Led "" 682 187 8   add:new Led "" 739 239 8   add:new Led "" 734 268 8   setFontSize:21   add:new Lab "5311" 590 113   add:new Lab "5312" 670 159   add:new Lab "5313" 714 187   add:new Lab "5314" 660 280   ico:655 123   box {     setFontSize:24           add:new Lab "(1802)" 70 5                 recall:"ТУ"     setFontSize:17     setColor:col.red col.black     add:new Lab 18 400 200     recall:"Ввод1"     setColor:col.white col     add:new Box "" 10 50 10 10 {       setColor:col.black col       setFontSize:12       ins:new Lab "яч.5 ПП-1808 116" 5 0     }     recall:"Ввод2"     setColor:col.white col     add:new Box "" 130 50 10 10 {       setColor:col.black col       ins:new Lab "яч.6 ПП-1808 206" 5 0     }     busWidth:10     setColor:col.blue col     add:new Bus "" 0 0 2 30 180 270 180     recall:"АГР1"     recall:"АГР2"     recall:"ЗВ"     recall:"ЛВ1"     recall:"ЛВ2"     recall:"ЛВ3"     recall:"ЛВ4"     moveX:74     busWidth:10     setColor:col.blue col     add:new Bus "" 0 0 2 40 300 PX 300   } } 

Здесь, например, метод recall, который ссылается на первый аргумент define — аналог evaluate в Groovy). Посмотрим, что там происходит:
src/1.txt

setColor:col.blue col busWidth:5 add:new Bus "" 65 60 2 0 0 0 120 { 	setColor:col.pink col 	add:new Box 88 -45 40 95 40 { 		setColor:col.red col.green 		add:new Led 90 5 5 19 		add:new Led 87 33 5 19 		add:new Box 86 66 8 23 23 { 			add:new Popup { 				setFontSize:17				 				add:new Btn 86 23 0 { 					add:new Act ON 131  					setColor:col.black col  					ins:new Lab "Включить" 5 5 				} 				add:new Btn 86 23 34 { 					add:new Act OFF 131  					setColor:col col.red 					add:new Box 131 8 8 15 15 { add:new Act TOG "" } 					setColor:col.black col 					ins:new Lab "Отключить" 25 5 				} 			} 		} 		setFontSize:12 		add:new Lab "МТЗ" 5 24 		add:new Lab "АВР" 30 24 	} }

src/2.txt

setColor:col.blue col busWidth:5 add:new Bus "" 180 60 2 0 0 0 120 { 	setColor:col.pink col 	add:new Box 94 -45 40 95 40 { 		setColor:col.red col.green 		add:new Led 96 5 5 19 		add:new Box 92 66 8 23 23 { 			add:new Popup { 				setFontSize:17				 				add:new Btn 92 23 0 { 					add:new Act ON 132					 					setColor:col.black col  					ins:new Lab "Включить" 5 5 				} 				add:new Btn 92 23 34 { 					add:new Act OFF 132 					setColor:col col.red 					add:new Box 132 8 8 15 15 { add:new Act TOG "" } 					setColor:col.black col					  					ins:new Lab "Отключить" 25 5 				} 			} 		} 		setFontSize:12 		add:new Lab "МТЗ" 5 24 	} } 

В этих файлах описаны два самых верхних устройства (Рис.1) и как можно заметить, присутствует некая повторяемость кода. Соответственно таким образом определяются шаблоны для всех комплексных блоков/устройств и затем пере-используются.
image
Рис. 1. Результат работы фрагмента скрипта

image
Рис. 2. Полная карта контактной сети и подстанций.

Приложение в таком виде эксплуатируется до сих пор (хотя за это время сменилось несколько поколений бэкенда!) и заказчика полностью устраивает, но меня все время подсознательно не покидала мысль избавиться от существующего “велосипеда”, хотя хочу отметить что в 99-м году он таковым еще не являлся. И вот, с появлением Spring 4 и очередного контекста на Groovy, покурив мануал мне показалось, что я где-то уже это видел)) (см. выше). Было решено, Spring Boot, Groovy-конфиг и JavaFx — новый стек для нового GUI — клиента…
Давайте рассмотрим архитектуру нового решения. И начнем с модели, которая представляет собой абстрактную обертку вокруг графических примитивов javafx и по сути является ядром приложения. Включает в себя ряд классов и интерфейсов. Абстрактный класс Unit является базовым для создания кастомного графического элемента из которых формируется карта и схемы.

public abstract class Unit<T> implements Render {    private static Logger log = LoggerFactory.getLogger(Unit.class);    private Integer code;    private State<T> state;    Node node;    private List<Unit> lays = new LinkedList<>();     public void init(Map<Integer, State> states) {        lays.forEach(e -> e.init(states));        initState(states);    }     private void initState(Map<Integer, State> states) {        if (code != null) {            State<T> state = states.get(code);            if (state == null) {                state = new State<>(code);                states.put(code, state);            }            state.addSlave(this);            this.state = state;        }    }     public void setCode(Integer code) {        this.code = code;    }     public Integer getCode() {        return code;    }     public State<T> getState() {        return state;    }     public void setGeom(double[] geom) {        node = createShape(geom);        initNodeEvents();    }     private void initNodeEvents() {        if (node != null) {            node.setOnMousePressed(this::mousePressed);            node.setOnMouseReleased(this::mouseReleased);        }    }     public void setLays(List<Unit> lays) {        this.lays = lays;    }     public void render(Group group) {        if (node != null) {            group.getChildren().add(node);        }        render();        lays.forEach(e -> e.render(group));    }     protected void mousePressed(MouseEvent e) {        log.debug(e.toString());    }     protected void mouseReleased(MouseEvent e) {        log.debug(e.toString());    }     protected abstract Node createShape(double[] geom); } 

Он имплементирует интерфейс Render, который управляет изменениями изображения

public interface Render {    void render();     void next(); } 

, где render() — отрисовка после изменения состояния;
next() — сменить кадр.
Каждый Unit имеет список

List lays вложенных Unit, что позволяет отрисовывать и управлять сценой по слоям. Он так же содержит методы для перехвата некоторых событий мыши.
В нашем случае Unit имеет наследника FlashingUnit:
public abstract class FlashingUnit<T> extends Unit<T> { private static Logger log = LoggerFactory.getLogger(FlashingUnit.class); @Override public void next() { log.debug("{} next()", this); if (getState() != null && node != null) { node.setVisible(!getState().isChanged() || !node.isVisible()); } } }

который как раз реализует смену кадров (мигание измененных объектов) в соответствии с нашим заданием.
В качестве примера привожу реализацию текстового графического элемента:

public class Lab extends FlashingUnit<Integer> {    private Color[] color;    private String text;    private Double size;    private Text shape;     @Override    protected Node createShape(double[] geom) {        //Can't set text without graphic context        shape = new Text(geom[0], geom[1], "");        if (size != null) {            shape.setFont(Font.font(size));        }        return shape;    }     public void setText(String text) {        this.text = text;    }     public void setSize(Double size) {        this.size = size;    }     public void setColor(Color[] color) {        this.color = color;    }     @Override    public void render(Group group) {        super.render(group);        shape.setText(text);    }     @Override    public void render() {        final Color[] c = new Color[]{color[0]};        if (getState() != null) {            getState().initValue(0);            c[0] = color[getState().getValue()];        }        shape.setFill(c[0]);    } } 

За состояние графических элементов отвечает класс State:

public class State<T> implements Render {    private final int id;    private T value;    private boolean changed;    private List<Render> slaves = new LinkedList<>();     public synchronized void initValue(T value) {        if (this.value == null) {            this.value = value;        }    }     public State(int id) {        this.id = id;    }     public int getId() {        return id;    }     public synchronized T getValue() {        return value;    }     public synchronized void setValue(T value) { if (!value.equals(this.value)) {    this.value = value;    changed = true;    render(); }    }     public synchronized boolean isChanged() {        return changed;    }     public synchronized void setChanged(boolean changed) {        this.changed = changed;    }     public void addSlave(Render unit) {        slaves.add(unit);    }     @Override    public void render() {        Platform.runLater(() -> slaves.forEach(Render::render));    }     @Override    public void next() {        Platform.runLater(() -> slaves.forEach(Render::next));    } } 

, который хранит информацию о состоянии и ее изменении всех элементов с общим code находящихся в List slaves и обеспечивает потокобезопасное обновление подчиненных элементов посредством интерфейса Render.
Класс Controller расширяет Unit и является контейнером для всех наших графических элементов и состояний объектов принадлежащих в данном случае отдельной подстанции с уникальным id
.

public class Controller extends Unit {    private int id;    private final Root root;    private final Map<Integer, State> states = new HashMap<>();     private Unit scheme;     @Autowired    public Controller(Root root) {        this.root = root;    }     @PostConstruct    void init() {        super.init(states);        if (scheme != null) {            scheme.init(states);        }        root.addController(this);    }     public void setId(int id) {        this.id = id;    }     public State getState(int code) {        return states.get(code);    }     public int getId() {        return id;    }     public void setScheme(Unit scheme) {        this.scheme = scheme;    }     public Unit getScheme() {        return scheme;    }     @Override    protected Node createShape(double[] geom) {        return null;    }     @Override    public void render() {        states.values().forEach(Render::render);    }     @Override    public void next() {        states.values().forEach(Render::next);    } } 

Ну и наконец класс Root, который содержит маппинг всех контроллеров (подстанций):

public class Root implements Render {    private final Map<Integer, Controller> controllers = new HashMap<>();     void addController(Controller controller) {        if (controllers.containsKey(controller.getId())) {            throw new DuplicateKeyException(String.format("Controller id %d already exists",controller.getId()));        }        controllers.put(controller.getId(), controller);    }     public Controller getController(int id) {        return controllers.get(id);    }     public State getState(int controllerId, int code) {        Controller controller = controllers.get(controllerId);        if (controller != null) {            return controller.getState(code);        }        return null;    }     public void render(Group group) {        controllers.values().stream().map(Controller::getScheme)                .filter(Objects::nonNull).forEach(r -> r.render(group));    }     	@Override public void render() {    controllers.values().forEach(Render::render); }  @Override public void next() {    controllers.values().forEach(Render::next); } } 

Здесь хочется отметить, что использование @PostConctruct init() и DI в Controller позволяет нам динамически создавать конфигурацию состояний объектов и избавить пользователя от лишнего кода в скрипте конфигурации.
А теперь давайте посмотрим как это все выглядит в Groovy - конфиге, собственно для чего все это мы и создавали:
root.groovy

package scheme  import com.ldim.granit.ui.model.Controller import com.ldim.granit.ui.model.shape.Box import com.ldim.granit.ui.model.shape.Bus import com.ldim.granit.ui.model.shape.Lab import com.ldim.granit.ui.model.shape.Led import javafx.scene.paint.Color  suplyColor = [Color.GREEN, Color.RED] alarmColor = [Color.RED, Color.GREEN]  beans {     importBeans('classpath:/scheme/srcs.groovy')    importBeans('classpath:/scheme/devs.groovy')    importBeans('classpath:/scheme/sws.groovy')    importBeans('classpath:/scheme/tsns.groovy')    importBeans('classpath:/scheme/ctrls.groovy')     controller2(Controller) {        id = 2        lays = [dev1]    }     tp9scheme(Bus) {        code = 5        color = suplyColor        width = 6        geom = [653, 764, 698, 687, 701, 631]        lays = [new Bus(code: 29, color: suplyColor, width: 6, geom: [701, 631, 701, 602]),                new Bus(code: 6, color: suplyColor, width: 6, geom: [701, 602, 705, 560, 840, 591]),                new Led(geom:[4.5, 701, 631, 701, 602]),                new Lab(text: '5092', geom: [705, 684]), new Lab(text: '5094', geom: [715, 612]), new Lab(text: '5093', geom: [764, 544]),                new Lab(text: '19 микрорайон', geom: [595, 630]),                new Box(code: 129, color: alarmColor, geom: [598, 593, 10, 10]),                new Box(code: 130, color: alarmColor, geom: [598, 603, 10, 10]),                new Box(color: [Color.GRAY], geom: [608, 593, 30, 20], lays: [new Lab(size: 11, text: 'ТП9', geom: [613, 607])])]    }     tp9(Controller) {        id = 3        lays = [src1, src2, src3,                new Bus (color: [Color.BLUE], width: 8, geom: [40, 176, 380, 176]),                dev1, dev2, dev3, dev4,                new Bus (color: [Color.BLUE], width: 8, geom: [40, 276, 585, 276]),                sw0, sw1, sw2, sw3, sw4, sw5, sw6,                new Bus (code: 12, color: suplyColor, width: 8, geom: [30, 350, 615, 350]),                lbl0, lbl1, lbl2, lbl3, lbl4,                tsn1, tsn2,                reqBtn, tstBtn, secBtn, okBtn]        scheme = tp9scheme    } } 

srcs.groovy

package scheme  beanName = 'src1' offsetX = 0 reserved = true devCode = 1 swCode = 11 led1Code = 11 led2Code = 11  evaluate(new File("./src/main/resources/scheme/templates/src.groovy"))  beanName = 'src2' offsetX = 120 reserved = false devCode = 1 swCode = 11 led1Code = 11 led2Code = 11  evaluate(new File("./src/main/resources/scheme/templates/src.groovy"))  beanName = 'src3' offsetX = 240 reserved = false devCode = 1 swCode = 11 led1Code = 11 led2Code = 11  evaluate(new File("./src/main/resources/scheme/templates/src.groovy")) 

src.groovy

package scheme.templates  import com.ldim.granit.ui.model.shape.Box import com.ldim.granit.ui.model.shape.Bus import com.ldim.granit.ui.model.shape.Lab import com.ldim.granit.ui.model.shape.Led import javafx.scene.paint.Color  devX = 40 devY = 100  beans {     "${beanName}led1"(Led) {        bean -> bean.scope = 'prototype'            code = led1Code            color = [Color.GREEN, Color.RED]            geom = [9, devX + 14 + offsetX, devY + 16]    }     "${beanName}lbl1"(Lab) {        bean -> bean.scope = 'prototype'            code = devCode            text = 'МТЗ'            size = 9            color = [Color.BLACK, Color.BLACK]            geom = [devX + 5 + offsetX, devY + 35]    }     if (reserved) {        "${beanName}led2"(Led) {            bean ->                bean.scope = 'prototype'                code = led2Code                color = [Color.GREEN, Color.RED]                geom = [9, devX + 38 + offsetX, devY + 16]        }         "${beanName}lbl2"(Lab) {            bean ->                bean.scope = 'prototype'                code = devCode                text = 'АВР'                size = 9                color = [Color.BLACK, Color.BLACK]                geom = [devX + 30 + offsetX, devY + 35]        }    }     "${beanName}btn"(Box) {        bean -> bean.scope = 'prototype'            code = swCode            color = [Color.GREEN, Color.RED]            press = true            geom = [devX + 66 + offsetX, devY + 8, 23, 23]    }     "${beanName}box"(Box) {        bean -> bean.scope = 'prototype'            code = devCode            color = [Color.GRAY, Color.PINK]            geom = [devX + offsetX, devY, 95, 40]            lays = reserved ? [ref("${beanName}btn"),                    ref("${beanName}led1"), ref("${beanName}lbl1"),                    ref("${beanName}led2"), ref("${beanName}lbl2")]                    : [ref("${beanName}btn"),                       ref("${beanName}led1"), ref("${beanName}lbl1")]     }     "${beanName}"(Bus) {        bean -> bean.scope = 'prototype'            color = [Color.BLUE]            width = 4            geom = [devX + 77 + offsetX, devY - 30, devX + 77 + offsetX, devY + 70]            lays = [ref("${beanName}box")]    } } 

Теперь, используя возможности Groovy, мы можем создавать различные конфигурации схем оборудования из имеющихся графических примитивов без переборки проекта. И эффективно переиспользовать имеющийся код(я специально показал здесь файлы srcs.groovy и src.groovy). Исходники прототипа лежат здесь.

Заключение.

Современный стек технологий Java позволяет реализовать эффективные и нестандартные вещи без использования “велосипедов”, относительно легко и в разумные сроки. Не зная Groovy да и собственно JavaFx, прототип нового приложения был реализован на “коленках” за несколько дней. И, что еще немаловажно - мы создали наше приложение с использованием мощных и открытых стандартов производственной разработки Java.
ссылка на оригинал статьи https://habrahabr.ru/post/328402/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *