Pull to refresh
85.82
SimbirSoft
Лидер в разработке современных ИТ-решений на заказ

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

Reading time 11 min
Views 4K
В конце 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.
Tags:
Hubs:
+4
Comments 0
Comments Leave a comment

Articles

Information

Website
www.simbirsoft.com
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия