Search code examples
javajavafxyamlsnakeyaml

Load LinkedList of JavaFX Shapes from YAML file?


I'm attempting to create a Java LinkedList from a sequence of JavaFX Shape objects stored in a YAML file using the SnakeYAML Java library.

I wrote the following simple Java class for saving & loading LinkedList objects to & from YAML files:

package com.example.test_1;

import javafx.stage.FileChooser;
import org.yaml.snakeyaml.Yaml;

import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.LinkedList;

public class YAML
{
    static public LinkedList<Object> YAML_ListLoad()
    {
        FileChooser fileChooser = new FileChooser();
        fileChooser.setTitle("Load");
        fileChooser.getExtensionFilters().addAll(new FileChooser.ExtensionFilter("YAML File", "*.yaml"));
        File file = fileChooser.showOpenDialog(App.MainStage);

        if (file == null)
        {
            System.out.println("Unable to load YAML file because the entered directory is invalid!");
        }
        else
        {
            try
            {
                return YAML_ListRead(file.getAbsolutePath());
            }
            catch (IOException e)
            {
                System.out.println("Encountered error while loading YAML file!");
            }
        }

        return null;
    }

    static public LinkedList<Object> YAML_ListRead(String filePath) throws IOException
    {
        Yaml yaml = new Yaml();
        FileReader fileReader = new FileReader(filePath);

        return yaml.load(fileReader);
    }

    static public boolean YAML_ListSave(LinkedList<Object> list)
    {
        FileChooser fileChooser = new FileChooser();
        fileChooser.setTitle("Save");
        fileChooser.getExtensionFilters().addAll(new FileChooser.ExtensionFilter("YAML File", "*.yaml"));
        fileChooser.setInitialFileName("file.yaml");
        File file = fileChooser.showSaveDialog(App.MainStage);

        if (file == null)
        {
            System.out.println("Unable to save YAML file because the entered directory is invalid!");
        }
        else
        {
            try
            {
                YAML_ListWrite(file.getAbsolutePath(), list);

                return true;
            }
            catch (IOException e)
            {
                System.out.println("Encountered error while saving YAML file!");
            }
        }

        return false;
    }

    static public void YAML_ListWrite(String filePath, LinkedList<Object> list) throws IOException
    {
        Yaml yaml = new Yaml();
        FileWriter fileWriter = new FileWriter(filePath);
        yaml.dump(list, fileWriter);
    }
}

Where YAML_ListLoad is a function that returns a LinkedList of objects it loads from whichever file the user chooses using the FileChooser, and YAML_ListSave is a function that saves the provided LinkedList to a YAML file that the user chooses.

The YAML class above is intended to be ran from the following testing functions:

public void Save()
{
    LinkedList<Object> shapes = new LinkedList<Object>();
    shapes.add(new Circle(5, Color.RED));
    shapes.add(new Circle(10, Color.BLUE));

    YAML.YAML_ListSave(shapes);
}

public void Load()
{
    LinkedList<Object> shapes = YAML.YAML_ListLoad();

    System.out.println(shapes);
}

Where Save simply creates 2 JavaFX Circle objects, and attempts to save them in a YAML file, and Load simply tries to load a YAML file into a LinkedList.

In the code above, the saving functions appear to work well. Running the Save function in the runner excerpt above results in the following YAML file being created:

- !!javafx.scene.shape.Circle
  accessibleHelp: null
  accessibleRole: NODE
  accessibleRoleDescription: null
  accessibleText: null
  blendMode: null
  cache: false
  cacheHint: DEFAULT
  centerX: 0.0
  centerY: 0.0
  clip: null
  cursor: null
  depthTest: INHERIT
  disable: false
  effect: null
  eventDispatcher: !!com.sun.javafx.scene.NodeEventDispatcher {}
  fill: !!javafx.scene.paint.Color {}
  focusTraversable: false
  id: null
  inputMethodRequests: null
  layoutX: 0.0
  layoutY: 0.0
  managed: true
  mouseTransparent: false
  nodeOrientation: INHERIT
  onContextMenuRequested: null
  onDragDetected: null
  onDragDone: null
  onDragDropped: null
  onDragEntered: null
  onDragExited: null
  onDragOver: null
  onInputMethodTextChanged: null
  onKeyPressed: null
  onKeyReleased: null
  onKeyTyped: null
  onMouseClicked: null
  onMouseDragEntered: null
  onMouseDragExited: null
  onMouseDragOver: null
  onMouseDragReleased: null
  onMouseDragged: null
  onMouseEntered: null
  onMouseExited: null
  onMouseMoved: null
  onMousePressed: null
  onMouseReleased: null
  onRotate: null
  onRotationFinished: null
  onRotationStarted: null
  onScroll: null
  onScrollFinished: null
  onScrollStarted: null
  onSwipeDown: null
  onSwipeLeft: null
  onSwipeRight: null
  onSwipeUp: null
  onTouchMoved: null
  onTouchPressed: null
  onTouchReleased: null
  onTouchStationary: null
  onZoom: null
  onZoomFinished: null
  onZoomStarted: null
  opacity: 1.0
  pickOnBounds: false
  radius: 5.0
  rotate: 0.0
  rotationAxis: &id001 {}
  scaleX: 1.0
  scaleY: 1.0
  scaleZ: 1.0
  smooth: true
  stroke: null
  strokeDashOffset: 0.0
  strokeLineCap: SQUARE
  strokeLineJoin: MITER
  strokeMiterLimit: 10.0
  strokeType: CENTERED
  strokeWidth: 1.0
  style: ''
  translateX: 0.0
  translateY: 0.0
  translateZ: 0.0
  userData: null
  viewOrder: 0.0
  visible: true
- !!javafx.scene.shape.Circle
  accessibleHelp: null
  accessibleRole: NODE
  accessibleRoleDescription: null
  accessibleText: null
  blendMode: null
  cache: false
  cacheHint: DEFAULT
  centerX: 0.0
  centerY: 0.0
  clip: null
  cursor: null
  depthTest: INHERIT
  disable: false
  effect: null
  eventDispatcher: !!com.sun.javafx.scene.NodeEventDispatcher {}
  fill: !!javafx.scene.paint.Color {}
  focusTraversable: false
  id: null
  inputMethodRequests: null
  layoutX: 0.0
  layoutY: 0.0
  managed: true
  mouseTransparent: false
  nodeOrientation: INHERIT
  onContextMenuRequested: null
  onDragDetected: null
  onDragDone: null
  onDragDropped: null
  onDragEntered: null
  onDragExited: null
  onDragOver: null
  onInputMethodTextChanged: null
  onKeyPressed: null
  onKeyReleased: null
  onKeyTyped: null
  onMouseClicked: null
  onMouseDragEntered: null
  onMouseDragExited: null
  onMouseDragOver: null
  onMouseDragReleased: null
  onMouseDragged: null
  onMouseEntered: null
  onMouseExited: null
  onMouseMoved: null
  onMousePressed: null
  onMouseReleased: null
  onRotate: null
  onRotationFinished: null
  onRotationStarted: null
  onScroll: null
  onScrollFinished: null
  onScrollStarted: null
  onSwipeDown: null
  onSwipeLeft: null
  onSwipeRight: null
  onSwipeUp: null
  onTouchMoved: null
  onTouchPressed: null
  onTouchReleased: null
  onTouchStationary: null
  onZoom: null
  onZoomFinished: null
  onZoomStarted: null
  opacity: 1.0
  pickOnBounds: false
  radius: 10.0
  rotate: 0.0
  rotationAxis: *id001
  scaleX: 1.0
  scaleY: 1.0
  scaleZ: 1.0
  smooth: true
  stroke: null
  strokeDashOffset: 0.0
  strokeLineCap: SQUARE
  strokeLineJoin: MITER
  strokeMiterLimit: 10.0
  strokeType: CENTERED
  strokeWidth: 1.0
  style: ''
  translateX: 0.0
  translateY: 0.0
  translateZ: 0.0
  userData: null
  viewOrder: 0.0
  visible: true

However, attempting to load that created YAML file back into a LinkedList by calling Save results in many errors, including:

Caused by: Cannot create property=eventDispatcher for JavaBean=Circle[centerX=0.0, centerY=0.0, radius=0.0, fill=0x000000ff]
 in 'reader', line 1, column 3:
    - !!javafx.scene.shape.Circle
      ^
Can't construct a java object for tag:yaml.org,2002:com.sun.javafx.scene.NodeEventDispatcher; exception=java.lang.InstantiationException: NoSuchMethodException:com.sun.javafx.scene.NodeEventDispatcher.<init>()
 in 'reader', line 16, column 20:
      eventDispatcher: !!com.sun.javafx.scene.NodeEvent ... 
                       ^

 in 'reader', line 16, column 20:
      eventDispatcher: !!com.sun.javafx.scene.NodeEvent ... 
                       ^

Can't construct a java object for tag:yaml.org,2002:com.sun.javafx.scene.NodeEventDispatcher; exception=java.lang.InstantiationException: NoSuchMethodException:com.sun.javafx.scene.NodeEventDispatcher.<init>()
 in 'reader', line 16, column 20:
      eventDispatcher: !!com.sun.javafx.scene.NodeEvent ... 
                       ^

It would appear to me that the entries marked by !! in the YAML file are treated as some sort of null or empty entries. It appears that the YAML parser isn't able to parse those.

What would be the proper way of loading a sequence of JavaFX Shape objects from a YAML file? Is it even possible to load JavaFX objects using SnakeYAML?

Thanks for reading my post, any guidance is appreciated.


Solution

  • An example of serializing and deserializing shapes based on a YAML file format.

    It uses Jackson to assist with the marshaling operations. Jackson (currently) uses SnakeYAML as its YAML implementation, though that is completely transparent to the usage via the Jackson API.

    It only allows a single shape type (a Circle) but could be extended to handle other shapes without much difficulty.

    App Output

    screenshot

    Wrote: /var/folders/87/r96kpgbj5tjdpsgml9gb_g8c0000gn/T/shapes-4277733445362494936.yaml
    ---
    - !<Circle>
      centerX: 40.0
      centerY: 50.0
      radius: 20.0
      fill: "BLUE"
    - !<Circle>
      centerX: 20.0
      centerY: 20.0
      radius: 10.0
      fill: "RED"
    
    Read: /var/folders/87/r96kpgbj5tjdpsgml9gb_g8c0000gn/T/shapes-4277733445362494936.yaml
    [CircleModel[centerX=40.0, centerY=50.0, radius=20.0, fill=BLUE], CircleModel[centerX=20.0, centerY=20.0, radius=10.0, fill=RED]]
    

    Alternate implementation using FXML

    You could store the objects as FXML, then you can load them using the FXMLLoader.

    That may work well if you have some tool to create the shapes and export them in FXML format.

    However, if you need to create an FXML file programmatically from model objects in your application, that might be a little difficult. I know of no easily available 3rd party library to do that. Jackson, similar to that used here for YAML, could create basic FXML files with a bit of additional programming. However, that is outside the scope of this answer.

    Using an alternate data format (JSON or XML)

    To use JSON as a serialization format, in the ShapeRepository class, use:

    private final JsonFactory serializationFactory = new JsonFactory();
    

    instead of the YamlFactory:

    private final JsonFactory serializationFactory = new YAMLFactory();
    

    Similarly for XML, see:

    App Code

    module-info.java

    module com.example.persistentcirclesapp {
        requires javafx.graphics;
        requires com.fasterxml.jackson.dataformat.yaml;
        requires com.fasterxml.jackson.databind;
    
        exports com.example.persistentcirclesapp;
    }
    

    pom.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>com.example</groupId>
        <artifactId>PersistentCirclesApp</artifactId>
        <version>1.0-SNAPSHOT</version>
        <name>PersistentCirclesApp</name>
    
        <properties>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-graphics</artifactId>
                <version>21.0.1</version>
            </dependency>
            <dependency>
                <groupId>com.fasterxml.jackson.dataformat</groupId>
                <artifactId>jackson-dataformat-yaml</artifactId>
                <version>2.15.3</version>
            </dependency>
            <dependency>
                <groupId>com.fasterxml.jackson.datatype</groupId>
                <artifactId>jackson-datatype-jsr310</artifactId>
                <version>2.15.3</version>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.11.0</version>
                    <configuration>
                        <source>21</source>
                        <target>21</target>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </project>
    

    ShapeApplication.java

    package com.example.persistentcirclesapp;
    
    import javafx.application.Application;
    import javafx.scene.Scene;
    import javafx.stage.Stage;
    
    import java.io.IOException;
    import java.util.List;
    
    public class ShapeApplication extends Application {
        @Override
        public void start(Stage stage) throws IOException {
            ShapeRepository shapeRepository = new ShapeRepository();
            shapeRepository.save(
                    List.of(
                            new CircleModel(40, 50, 20, "BLUE"),
                            new CircleModel(20, 20, 10, "RED")
                    )
            );
    
            List<ShapeModel> shapes = shapeRepository.load();
    
            ShapeRenderer shapeRenderer = new ShapeRenderer();
    
            stage.setScene(new Scene(shapeRenderer.render(shapes)));
            stage.show();
        }
    
        public static void main(String[] args) {
            launch();
        }
    }
    

    ShapeModel.java

    package com.example.persistentcirclesapp;
    
    import com.fasterxml.jackson.annotation.JsonSubTypes;
    import com.fasterxml.jackson.annotation.JsonTypeInfo;
    
    @JsonTypeInfo(
            use = JsonTypeInfo.Id.NAME,
            include = JsonTypeInfo.As.WRAPPER_OBJECT
    )
    @JsonSubTypes({
            @JsonSubTypes.Type(value = CircleModel.class, name = "Circle")
    })
    public sealed interface ShapeModel permits CircleModel {}
    

    CircleModel.java

    package com.example.persistentcirclesapp;
    
    public record CircleModel(
            double centerX,
            double centerY,
            double radius,
            String fill
    ) implements ShapeModel {}
    

    ShapeRepository.java

    package com.example.persistentcirclesapp;
    
    import com.fasterxml.jackson.core.JsonFactory;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.fasterxml.jackson.databind.ObjectReader;
    import com.fasterxml.jackson.databind.ObjectWriter;
    import com.fasterxml.jackson.databind.type.CollectionType;
    import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
    
    import java.io.IOException;
    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.util.List;
    
    public class ShapeRepository {
        private final Path shapeRepositoryPath;
    
        private final JsonFactory serializationFactory = new YAMLFactory();
        private final ObjectMapper mapper =
                new ObjectMapper(serializationFactory);
    
        private final CollectionType shapeCollectionType = mapper.getTypeFactory().constructCollectionType(
                List.class, ShapeModel.class
        );
    
        private final ObjectWriter writer = new ObjectMapper(serializationFactory)
                .writerFor(shapeCollectionType);
    
        private final ObjectReader reader = new ObjectMapper(serializationFactory)
                .readerFor(shapeCollectionType);
    
        public ShapeRepository() throws IOException {
            shapeRepositoryPath = Files.createTempFile("shapes-", ".yaml");
        }
    
        public ShapeRepository(Path shapeRepositoryPath) {
            this.shapeRepositoryPath = shapeRepositoryPath;
        }
    
        public void save(List<ShapeModel> shapes) throws IOException {
            writer.writeValue(
                    shapeRepositoryPath.toFile(),
                    shapes
            );
    
            System.out.println("Wrote: " + shapeRepositoryPath + "\n" + Files.readString(shapeRepositoryPath));
        }
    
        public List<ShapeModel> load() throws IOException {
            List<ShapeModel> shapes = reader.readValue(
                    shapeRepositoryPath.toFile()
            );
    
            System.out.println("Read: " + shapeRepositoryPath + "\n" + shapes);
    
            return shapes;
        }
    }
    

    ShapeRenderer.java

    package com.example.persistentcirclesapp;
    
    import javafx.scene.layout.Pane;
    
    import java.util.List;
    
    public class ShapeRenderer {
        private final ShapeFactory shapeFactory = new ShapeFactory();
    
        public Pane render(List<ShapeModel> shapes) {
            Pane pane = new Pane();
    
            shapes.stream()
                    .map(shapeFactory::createShape)
                    .forEach(shape ->
                            pane.getChildren().add(shape)
                    );
    
            return pane;
        }
    }
    

    ShapeFactory.java

    package com.example.persistentcirclesapp;
    
    import javafx.scene.paint.Paint;
    import javafx.scene.shape.Circle;
    import javafx.scene.shape.Shape;
    
    public class ShapeFactory {
        public Shape createShape(ShapeModel shapeModel) {
            return switch(shapeModel) {
                case CircleModel circleModel ->
                        new Circle(
                                circleModel.centerX(),
                                circleModel.centerY(),
                                circleModel.radius(),
                                Paint.valueOf(circleModel.fill())
                        );
            };
        }
        
        public ShapeModel createModel(Shape shape) {
            return switch(shape) {
                case Circle circle ->
                        new CircleModel(
                                circle.getCenterX(), 
                                circle.getCenterY(), 
                                circle.getRadius(), 
                                circle.getFill().toString() 
                        );
                default -> 
                        throw new IllegalArgumentException("Unsupported shape type " + shape.getClass().getName());
            };
        }
    }