DEV Community

Cover image for Devlog - Creating a MVCI JavaFX Application
Amanuel Zeleke
Amanuel Zeleke

Posted on • Updated on

Devlog - Creating a MVCI JavaFX Application

Introduction

In this Devlog, I plan to detail the application I have been developing and the lessons I have learnt from choosing JavaFX as my platform to build my application on.

List of dependancies used: Source Code

Concept

The project began as a CLI-style mini-Java application. At my workplace, we use Docker extensively, so I aimed to build a tool to support developers with a similar tech stack. The initial application used the Scanner class to take user input and execute commands like docker ps.

public static void dockerMenu(Scanner input) {
        out.println("╔══════════════════════════════╗");
        out.println("║        Docker Commands       ║");
        out.println("╚══════════════════════════════╝");
        out.println("     1. ➤ Option 1: Docker PS");
        out.println("     2. ➤ Option 2: Docker Images");
        out.println("     3. ➤ Option 3: Back");
        out.println("\nPlease select an option (1-3): ");
        int selection = input.nextInt();
        input.nextLine();

        String selectedOption = getAndHandleUserInput(selection, 3);
        if (selectedOption != null) {
            out.println("Selected option: " + selectedOption);
            switch (selectedOption) {
                case "selectedMenu1": {
                    DockerCLI.runDockerCommand("ps");
                    break;
                }
                case "selectedMenu2": {
                    DockerCLI.runDockerCommand("images");
                    break;
                }
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Whilst useful and easily extensible, I felt the need to still access docker to do more complicated actions. This became the basis of the problem that my application will grow to solve - how to interact with docker without using the docker desktop or docker CLI.

My solution was to build an application that will streamline how a user interacts with docker through a neat GUI built with JavaFX. In time, I will add more features that will go beyond what Docker Desktop already does, but to keep the scope focused, I decided to remake the core features in the application first.

Planning the UI

I used Figma, a fantastic tool for design and prototyping, to create quick wireframes for guiding the UI development. This approach prevents the common pitfall of building an ad-hoc UI that can quickly become unmanageable. Instead, it provides constraints and clear goals for development. Link to Figma Board

Wireframes

First Blocker

JavaFX has a preference to be ran inside a modular project:

module nivohub.digitalartifactmaven {
    requires javafx.controls;
    requires javafx.fxml;
    requires docker.client;

    exports nivohub.devInspector;
    exports nivohub.devInspector.view;
    exports nivohub.devInspector.model;
    exports nivohub.devInspector.controller;
}
Enter fullscreen mode Exit fullscreen mode

As the project grew, I encountered split package issues with the Docker Java API. These issues arose because the Docker Java API library has the same package names split across multiple modules that conflict with the strict module boundaries enforced in a modular project - Solutions that may work for you in this event

Since I could not find an easy solution for using this library in a modular project, I opted to forgo the benefits introduced in Java 9 and made my project non-modular by creating a launcher class to bypass the module system constraints Deploying non-modular JavaFX applications.

package nivohub.devInspector;

public class MainApplicationLauncher {
    public  static void main (String[] args) {
        AppRoot.main(args);
    }
}

Enter fullscreen mode Exit fullscreen mode

I was not too worried about this because the system architecture I chose inherently provides benefits like encapsulation, separation of concerns, and reinforces single responsibility when implemented correctly. Therefore, managing the visibility of internal classes seemed excessive for this project.

Wrestling with Model-View-Controller

I initially built the project with an MVC system where the view - consumes the model's data and the controller - takes actions from the user, driven by events in the view, which then in turns updates the model to be represented in the view.

public class DockerController {
    private final DockerScene view;
    private final DockerManager model;

    public DockerController(DockerScene view, DockerManager model) {
        this.view = view;
        this.model = model;

        populateImageSelection();
        setupImageSelectionListener(view);
        handleRunButtonAction();
    }
Enter fullscreen mode Exit fullscreen mode

This introduced a few challenges:

1. Limited documentation on MVC in JavaFX

The first issue in choosing this system for my application was the limited information available online on how to properly implement MVC in JavaFX - this, coupled with contradictions online and my own misunderstandings of MVC led to tightly coupled classes. For instance, in the DockerScene class, the controller setup is tightly coupled, requiring type checks and casting:

   public DockerScene(AppMenu appMenu) {
        super(appMenu);
        this.appMenu = appMenu;
    }

    @Override
    public void setController(Object controller) {
        if (controller instanceof DockerController) {
            this.controller = (DockerController) controller;
        } else {
            throw new IllegalArgumentException("Controller must be a DockerController");
        }
    }
Enter fullscreen mode Exit fullscreen mode

2. Model and Domain Objects

Another challenge was managing the complexity of the Model. The Model contains all the data and logic to interface with external parts of the system, such as databases or external APIs.

In my DockerManager class, the model not only holds data but also interacts directly with the Docker API, creating a potential single point of failure and making the system more difficult to test and maintain.

MVC Model

3. Difficulty in Scaling and Maintaining the Application

Another significant challenge with MVC in JavaFX is scaling and maintaining the application as it grows. The tight coupling between controllers and views, along with complex models, makes the application harder to extend with new features.

As the application grows, the interdependencies between different parts of the system become more pronounced, making it difficult to isolate and modify individual components without affecting others. This complexity can lead to increased development time, more bugs, and higher maintenance. Cohesion and Coupling

Model-View-Controller-Interactor

To address the challenges I faced with MVC in JavaFX, I transitioned to the Model-View-Controller-Interactor (MVCI) framework.
Fantastic resource on MVCI in JavaFX

MVCI System

Unlike MVC, MVCI includes an Interactor component that manages the application's business logic and interactions with external systems, like APIs or databases.

This separation ensures that the Model only contains state data, making the system easier to test and maintain.

The Interactor handles complex operations, reducing the tight coupling between components and allowing for more scalable and modular application architecture.

Model

In this example below our UserModel is a POJO that takes advantage of JavaFX's bindable properties to store and allow observation of our data to our view:

public class UserModel {
    private final StringProperty fullName = new SimpleStringProperty("");
    private final StringProperty inputPassword = new SimpleStringProperty("");
    private final BooleanProperty loginFailed = new SimpleBooleanProperty(this, "loginFailed", false);
    private final BooleanProperty authenticated = new SimpleBooleanProperty(this, "authorized", false);
    private final String password = "letmein";
    private String platform;
    private String osArch;

    public BooleanProperty loginFailedProperty(){
        return loginFailed;
    }

    public BooleanProperty authenticatedProperty() {
        return authenticated;
    }

    public StringProperty fullNameProperty() {
        return fullName;
    }

    public StringProperty inputPasswordProperty() {
        return inputPassword;
    }

    public String getFullName(){
        return fullName.get();
    }

    public String getPassword() {
        return password;
    }

    public String getInputPassword() {
        return inputPassword.get();
    }
Enter fullscreen mode Exit fullscreen mode

The UserModel maintains user data and state, such as fullName, inputPassword, loginFailed, and authenticated.

View

The view establishes bindings to properties in the model to represent the data to the user.

In the example below the LoginViewBuilder binds the input properties of the name and password to the simple properties in UserModel.

It can also take parameters such as a Runnable or Consumer to handle user events and inform the controller... whilst remaining agnostic of what class provides this:

public class LoginViewBuilder implements Builder<Region> {

    private final UserModel model;
    private final Runnable loginHandler;

    public LoginViewBuilder(UserModel model, Runnable loginHandler) {
        this.model = model;
        this.loginHandler = loginHandler;
    }

    @Override
    public Region build() {
        GridPane results = new GridPane();
        results.setPadding(new Insets(25));

        results.setHgap(10);
        results.setVgap(10);
        results.setAlignment(Pos.CENTER);

        results.add(welcomeLabel(), 0, 0);
        results.add(boundNameLabel(), 1, 0, 2, 1);
        results.add(nameLabel(), 0, 1);
        results.add(nameInput(), 1, 1);
        results.add(passwordLabel(), 0, 2);
        results.add(passwordInput(), 1, 2);
        results.add(createLoginButton(), 1, 3);
        results.add(errorMessage(), 1, 4);

        return results;
    }


    private Node createLoginButton() {
        Button loginButton = new Button("Login");
        loginButton.setOnAction(event -> loginHandler.run());
        return loginButton;
    }

    private Node passwordInput(){
        return boundPasswordField(model.inputPasswordProperty(), "Enter Password", loginHandler);
    }

    private Node nameInput(){
        return boundTextField(model.fullNameProperty(), "Enter Full Name", loginHandler);
    }
Enter fullscreen mode Exit fullscreen mode

I am also implementing an interface provided by JavaFX called Builder<> which allows the controller to expose a method to create a view without having to reference the view or its dependancies!

public abstract class BaseController {
    protected Builder<Region> viewBuilder;

    public Region getView() {
        return viewBuilder.build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Controller

The controller's job is simply to distributes 'jobs' to interactors and other controllers.

In this example, the LoginController instantiates the interactor and the view. By implementing the BaseController, we inherit the public method to build the view from the controller as well:

public class LoginController extends BaseController{
    private final LoginInteractor interactor;
    private final ApplicationController applicationController;

    public LoginController(UserModel model, ApplicationController applicationController) {
        interactor = new LoginInteractor(model);
        viewBuilder = new LoginViewBuilder(model, this::loginUser);
        this.applicationController = applicationController;
    }

    private void loginUser() {
        Task<Boolean> task = new Task<>() {
            @Override
            protected Boolean call() throws PasswordException {
                return interactor.attemptLogin();
            }
        };
        task.setOnSucceeded(e -> {if (Boolean.TRUE.equals(task.getValue())){
            interactor.updateFailedLogin(false);
            applicationController.loadMainView();
        }
        });
        task.setOnFailed(e -> {
            interactor.updateFailedLogin(true);
        });
        task.run();
    }
}
Enter fullscreen mode Exit fullscreen mode

In more complex/fat controllers, using interfaces allows us to effectively share methods across the application to other controllers:

public interface DockerInterface {
    void connectDocker();
    void disconnectDocker();
    void startContainer(String containerId);
    void stopContainer(String containerId);
    void removeContainer(String containerId);
    void stopThread();
}
Enter fullscreen mode Exit fullscreen mode

For example, the MenuBarController uses the interface implementations from the DockerController without being aware of the controller itself:

public class MenuBarController extends BaseController{
    private final MenuBarInteractor interactor;

    public MenuBarController(ApplicationModel applicationModel, DockerInterface dockerInterface, DockerModel dockerModel, ApplicationInterface applicationInterface) {
        interactor = new MenuBarInteractor(applicationModel);
        viewBuilder = new MenuBarBuilder(this::handleView, dockerInterface, dockerModel, applicationInterface);
    }
Enter fullscreen mode Exit fullscreen mode

This interface is then used in the view to handle user-driven events like connecting and disconnecting the Docker connection (This is also how I handle the user input to change the views with the MenuBar !):

Node connectDockerButton = styledRunnableButton("Connect", dockerInterface::connectDocker);
                connectDockerButton.disableProperty().bind(dockerModel.dockerConnectedProperty());

Node disconnectDockerButton = styledRunnableButton("Disconnect", dockerInterface::disconnectDocker);
                disconnectDockerButton.disableProperty().bind(dockerModel.dockerConnectedProperty().not());
Enter fullscreen mode Exit fullscreen mode

Interactor

The interactor bridges our system. Below is the LoginInteractor, which has two public methods: attemptLogin() and updateFailedLogin(). The interactor handles the business logic, such as verifying user credentials against the stored password in the model, and updates the state accordingly.

public class LoginInteractor {
    private final UserModel model;

    public LoginInteractor(UserModel model) {
        this.model = model;
        model.authenticatedProperty().bind(Bindings.createBooleanBinding(this::isPasswordValid, model.inputPasswordProperty()));
    }
    private boolean isPasswordValid(){
        return model.getPassword().equals(model.getInputPassword());
    }

    public boolean attemptLogin() throws PasswordException {
        if (isPasswordValid()){
            return true;
        } else {
            throw new PasswordException("Invalid password");
        }
    }

    public void updateFailedLogin(Boolean result){
        model.loginFailedProperty().set(result);
    }
}
Enter fullscreen mode Exit fullscreen mode

These methods are called from the LoginController as a Task and on success/failure of a Task. This maintains a separation of concerns between workflow control/job distribution and business logic.

Interactors however, allow us to uniquely interact with the outside e.g. domain objects. For example, methods for Docker communication are encapsulated in a domain object DockerEngine, initialised by the DockerInteractor, which accesses the necessary methods within the business logic.

Docker DOM

That wraps up the crux of the MVCI system. PragmaticCoding is a blog written by a retired expert in the field of which I learnt most of this system from: ProgramaticCoding

Tasks and Threads

One key constraint in using JavaFX is that it handles updates to the UI/Scene graph on a single thread known as the FXAT (JavaFX Application Thread). Meaning all changes to the UI must be done on the FXAT.

It also has no concept of time and is simply a queue of jobs to be executed in order. PragmaticCoding covers this in detail here: FXAT

Since my application revolves around communicating with Docker, which includes potential long running tasks, it would not be performant to execute those tasks on the FXAT...

This is where Tasks come in:

  @Override
    public void connectDocker() {
        interactor.addToOutput("Connecting to Docker...");
        Task<Void> task = new Task<>() {
            @Override
            protected Void call() throws DockerNotRunningException {
                interactor.connectDocker();
                return null;
            }
        };
        task.setOnSucceeded(e -> {
            interactor.addToOutput("Connected to Docker");
            interactor.updateModelConnection(true);
            interactor.listContainers();
            interactor.listImages();
        });
        task.setOnFailed(e -> interactor.addToOutput("Failed to connect to Docker :"+e.getSource().getException().getMessage()));
        new Thread(task).start();
    }
Enter fullscreen mode Exit fullscreen mode

Task is an abstract generic class that has methods for communicating between a background thread and the FXAT and a single abstract method name call().

In my example above, the task containing the call to connectDocker() runs on a new background thread (this is because I am using apacheHttp5 to communicate with docker for window users which is a long running task). On success or failure, it updates the UI on the FXAT e.g. interactor.addToOutput("Connected to Docker")

This prevents the long running tasks from blocking the UI and making it unresponsive as it is ran on a background thread and while still being able to update UI on the FXAT when the task gets completed.

Future Plans

This DevLog covers the current state of my project. Moving forward, I plan to enhance this application by integrating industry-leading libraries like Guice for dependency injection, which will not only simplify writing tests but also improve overall code modularity and manageability. I also will aim to address potential threading issues, such as memory leaks and deadlocks, by implementing thread pooling or exploring green threads. This will ensure more efficient and safe multi-threading within the application.

I attempted to manage distribution in a .app for MacOS users and provided a .bat for Windows but there are simpler packaging solutions that can make this application run across platforms like making an executable jar.

Beyond that, I want to extend the features of the application to provide a unique use case that is differentiated from the Docker Desktop Client. The investment so far has given me an extensible foundation to do so.

Top comments (0)