4

I have a system that processes Task objects, and now I would like to perform some benchmarking experiments. Specifically, I will create many (~100) Task objects, each belonging to one group of tasks, and I want to run the system on entire groups of tasks. I'd like a design that makes it easy to create a new Task and associate it with a group (easily diversify the benchmark suite). There are only a handful of groups, so some per-group infrastructure is acceptable.

Tasks can contain arbitrary Objects, so I can't load them from "data" file types like JSON -- only Java code is general enough to create the Tasks. Furthermore, for maintainability, I'd like to create each Task in a separate Java file:

// SolveHaltingProblem.java
public class SolveHaltingProblem {
    static Task getTask() {
        Task task = new Task();
        task.setName("SolveHaltingProblem");
        ... // Create task
        return task;
    }

    static String getGroup() {
        return "impossible-tasks";
    }
}

Then, it should be easy to gather groups of Tasks:

List<Task> tasks = gatherTasksInGroup("impossible-tasks");

without something silly and error-prone like:

List<Task> gatherTasksInGroup(String groupName) {
    List<Task> list = new ArrayList<>();
    if (groupName.equals("impossible-tasks")) {
        list.add(SolveHaltingProblem.getTask());
        list.add(SomeOtherHardProblem.getTask());
        ...
    } else if (...) {
        ... // ~100 more lines
    }
    return list;
}

I provide the above code just to help communicate my needs, and the design details aren't set in stone. Maybe it's better to have SolveHaltingProblem extend ImpossibleTaskGroup which extends TaskGroup...

I'm aware of the pattern where classes register themselves with other classes (is there a name for this?), but I don't think that pattern applies here since I'm not creating instances of SolveHaltingProblem, and thus I can't force any static initializers of SolveHaltingProblem to run. I've also tried to search StackOverflow for similar questions, but I've found that this question is quite hard to describe; I apologize if this is a duplicate.

So in summary, my question is: how can I manage groups of Tasks so that it's easy to add a new Task and associate it with a group? A good design would have the following properties (ordered from greatest to least importance):

  1. Each Task is created in a separate file
  2. Adding a new Task and associating it with a group only involves adding/changing one file
  3. We only construct Task objects for the requested groups
  4. Creating a new group is "easy" by copy/pasting infrastructure for an existing group with minor modifications
  5. We don't iterate over all classes in the classpath, or iterate over files in the filesystem
k_ssb
  • 6,024
  • 23
  • 47
  • I don't understand the use of the class SolveHaltingProblem, why not only work with Task instances? Group is only a string today so it can be made into a member of Task or you can take a completely different route and make Group into a class that holds a number of Task objects in a collection. I would look into using a factory class or factor methods to create tasks – Joakim Danielson Apr 03 '18 at 08:45
  • @JoakimDanielson I guess the SolveHaltingProblem class isn't strictly necessary, but it's a side-effect of wanting each Task defined in a separate file (to avoid excessively long Java files and having to Ctrl-F to modify a particular Task). Of course, each Java file must define a separate class (e.g., SolveHaltingProblem). I also don't think the Factory pattern applies, since I'm not asking for one task at a time (I want all tasks in a group, without explicitly listing each class/file/method that constructs a task in that group). – k_ssb Apr 03 '18 at 08:52
  • You need to understand the difference between a class and an instance of a class, an object. You will need one file for your Task class and then you can create as many different objects from that class as you want without needing any further files apart from the code that creates those objects. – Joakim Danielson Apr 03 '18 at 11:16
  • I do understand the difference. Maybe I should point out that Task objects are quite complicated and each Task requires 20-50 lines of code to set up (and no, there isn't any simpler way to create them). Because the "code that creates those objects" is complicated, I want to separate them out into different files (or at the very least, different methods). – k_ssb Apr 03 '18 at 11:32
  • This question is being discussed on [Meta](https://meta.stackoverflow.com/questions/366306/how-can-i-improve-my-poorly-received-questions) – TylerH Apr 21 '18 at 19:23
  • Should this be migrated to Software Engineering? – k_ssb Apr 21 '18 at 22:23

2 Answers2

6

One solution is to use the Composite Pattern, where the interface is Task, the terminal class is ExecutableTask (what was Task in your question), and the composite class is TaskGroup. An example implementation is as follows:

public interface Task {
    public void execute();
}

public class ExecutableTask implements Task {

    @Override
    public void execute() {
        // Do some work
    }
}

public class TaskGroup implements Task {

    private final String name;
    private final List<Task> tasks = new ArrayList<>();

    public TaskGroup(String name) {
        this.name = name;
    }

    @Override
    public void execute() {
        for (Task task: tasks) {
            task.execute();
        }
    }

    public void addTask(Task task) {
        tasks.add(task);
    }

    public String getName() {
        return name;
    }
}

This will allow you to execute groups of tasks in exactly the same way you execute a single task. For example, given that we have some method, benchmark, that benchmarks a Task, we can supply either an ExecutableTask (a single task) or TaskGroup:

public void benchmarkTask(Task task) {

    // Record start time

    task.execute();

    // Record completion time
}

// Execute single task
Task oneOffTask = new ExecutableTask();
benchmarkTask(oneOffTask);

// Execute a group of tasks
TaskGroup taskGroup = new TaskGroup("someGroup");
taskGroup.addTask(new ExecutableTask());
taskGroup.addTask(new ExecutableTask());
benchmarkTask(taskGroup);

This pattern can also be extended by creating various Task implementations (or extending ExecutableTask) that match different tasks that can be completed. For example, suppose we have two distinct types of tasks: Color tasks and timed tasks:

public class ColorTask implements Task {

    private final String color;

    public ColorTask(String color) {
        this.color = color;
    }

    @Override
    public void execute() {
        System.out.println("Executed task with color " + color);
    }
}

public class TimedTask implements Task {

    private final long seconds;

    public TimedTask(int seconds) {
        this.seconds = seconds * 1000;
    }

    @Override
    public void execute() {
        Thread.sleep(seconds * 1000);
        System.out.println("Executed timed task for " + seconds + " seconds");
    }
}

You could then create a TaskGroup of disparate Task implementations and run them without any type-specific logic:

TaskGroup group = new TaskGroup("differentTasksGroup");
group.addTask(new ColorTask("red"));
group.addTask(new TimedTask(2));
group.execute();

This would result in the following output:

Executed task with color red
Executed timed task for 2 seconds

Loading New Tasks

With this composite structure in place, you can use the Service Provider Interface (SPI) provided by Java. Although I won't go into detail here (the linked page contains a wealth of knowledge about how to setup and register services), the general idea is that you can have the Task interface act as the service interface and then load the various tasks that you registered as services. For example:

public class TaskServiceMain {

    public static void main(String[] args) {
        TaskGroup rootGroup = new TaskGroup("root");
        ServiceLoader serviceLoader = ServiceLoader.load(Task.class);

        for (Task task: serviceLoader) {
            rootGroup.addTask(task);
        }

        // Execute all of the tasks
        rootGroup.execute();
    }
}

It may be strange to use the root of the composite hierarchy (the Task interface) as the service interface, so creating a new service interface may be a good idea. For example, a new interface can be created that simply acts as a marker interface that adds no new functionality:

public interface ServiceTask extends Task {}

The service loading logic then becomes:

public class TaskServiceMain {

    public static void main(String[] args) {
        TaskGroup rootGroup = new TaskGroup("root");
        ServiceLoader serviceLoader = ServiceLoader.load(ServiceTask.class);

        for (ServiceTask task: serviceLoader) {
            rootGroup.addTask(task);
        }

        // Execute all of the tasks
        rootGroup.execute();
    }
}
Justin Albano
  • 3,809
  • 2
  • 24
  • 51
  • Thanks for the answer -- this is a nifty pattern that I haven't seen before. But can you clarify how this helps solve my core issue, that creating a new Task and adding it to a group should only involve changing/adding one file? I've thought about this issue more and I think this can only be solved via reflection by iterating through classes from the classloader (have each task-creation-class register itself with a group in a static initializer, and force those classes to be initialized via reflection). – k_ssb Apr 03 '18 at 23:58
  • I definitely understand your goal much more after your comment. I updated the answer above (the **Loading New Tasks** section) that explains how to tie service registration in with the composite pattern. Let me know if that answers your question. – Justin Albano Apr 04 '18 at 01:16
  • I read a bit about the SPI (also new to me, thanks!) and it seems that registering a service provider (in this context, a new Task) involves adding the new class's name to some configuration file. To me this is just as annoying/error-prone as the "silly and error-prone" code example in the original question. I think I've found my own solution to this problem, and I'll post it as an answer shortly. I'd welcome your comments/feedback on my approach then! – k_ssb Apr 04 '18 at 03:52
1

OP here, answering their own question.

I ended up using an approach where each Task-creating class (TaskCreator) registers itself with a group in the class's static initializer, and reflection is used to explicitly initialize those classes.

I used Google Guava Reflection library in TaskGroup.java to iterate over classes, because I already had a dependency on Guava elsewhere. Other approaches are covered in this question.

This approach successfully achieves the first 4 of my stated goals, but not the 5th. (It might be impossible to satisfy all 5 goals simultaneously.)

The pros of this approach are:

  • Each Task is created in a separate file, e.g., HaltingProblem.java or PerformAddition.java (goal 1)
  • Creating a Task and associating it with a group only involves 1 file (goal 2)
  • Task objects are only created when requested, in TaskGroup.getTasks() (goal 3)
  • Creating a new group is a one-line change, i.e., adding an enum value in TaskGroup.java (goal 4)
  • Creating a new TaskCreator by copy/pasting an existing one is very self-explanatory and hard to mess up
  • Each TaskCreator can assign its Task to any number of groups in the obvious way
  • We can loop over all groups (example in Main.java)
  • We have a handle to every Task through its TaskCreator (example in Main.java)
  • The compiler ensures that tasks may only be registered to groups that exist (which can't happen when using a String for the group name)

Some cons are:

  • We don't satisfy goal 5 -- the static initializer of TaskGroup loops over classes in the classpath
  • It is tricky to implement this approach without a dependency on some reflection library
  • Each Task can be created multiple times if it's in many groups (although one could implement a caching mechanism to fix this)
  • This approach heavily relies on static initializers, which are generally disliked
  • Every TaskCreator is a singleton, which are generally disliked

Code Example

Parts were inspired by this answer

Task.java

package my.packagename;

public class Task {
    private String name;
    // and other data

    public void setName(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    // and other getters/setters
}

TaskCreator.java

package my.packagename;

import my.packagename.Task;

public interface TaskCreator {
    public abstract Task createTask();
}

TaskGroup.java

package my.packagename;

import java.io.IOException;
import java.lang.reflect.Modifier;
import java.util.*;

import com.google.common.reflect.ClassPath;
import com.google.common.reflect.ClassPath.ClassInfo;

public enum TaskGroup {
    IMPOSSIBLE,
    EASY,
    COOL;

    static {
        Class<?> creatorBase = TaskCreator.class;
        try {
            ClassLoader loader = Thread.currentThread().getContextClassLoader();
            Set<ClassInfo> infoSet = ClassPath.from(loader).getTopLevelClassesRecursive("my.packagename");
            for (ClassInfo info : infoSet) {
                Class<?> cls = info.load();
                if (creatorBase.isAssignableFrom(cls) && !Modifier.isAbstract(cls.getModifiers()))
                    Class.forName(cls.getName()); // runs static initializer for cls
            }
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    private final List<TaskCreator> creators = new ArrayList<>();

    public void register(TaskCreator task) {
        creators.add(task);
    }

    public List<Task> getTasks() {
        List<Task> tasks = new ArrayList<>();
        for (TaskCreator creator : creators)
            tasks.add(creator.createTask());
        return tasks;
    }
}

HaltingProblem.java

package my.packagename;

public enum HaltingProblem implements TaskCreator {
    INSTANCE;
    static {
        TaskGroup.IMPOSSIBLE.register(INSTANCE);
        TaskGroup.COOL.register(INSTANCE);
    }

    public Task createTask() {
        Task task = new Task();
        task.setName("Halting problem");
        // and other halting-problem-specific Task setup
        return task;
    }
}

PerformAddition.java

package my.packagename;

public enum PerformAddition implements TaskCreator {
    INSTANCE;
    static {
        TaskGroup.EASY.register(INSTANCE);
        TaskGroup.COOL.register(INSTANCE);
    }

    public Task createTask() {
        Task task = new Task();
        task.setName("Perform addition");
        // and other addition-specific Task setup
        return task;
    }
}

Main.java

package my.packagename;

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Task> impossible = TaskGroup.IMPOSSIBLE.getTasks();
        for (Task task : impossible)
            System.out.println("Impossible task: " + task.getName());
        System.out.println();

        List<Task> easy = TaskGroup.EASY.getTasks();
        for (Task task : easy)
            System.out.println("Easy task: " + task.getName());
        System.out.println();

        List<Task> cool = TaskGroup.valueOf("COOL").getTasks();
        for (Task task : cool)
            System.out.println("Cool task: " + task.getName());
        System.out.println();

        // Can iterate through groups
        for (TaskGroup group : TaskGroup.values())
            System.out.println("Group " + group + " has " + group.getTasks().size() + " task(s)");

        // Can easily get a specific Task through its creator
        Task haltingProblemTask = HaltingProblem.INSTANCE.createTask();
    }
}
k_ssb
  • 6,024
  • 23
  • 47