Skip to content

Latest commit

 

History

History
239 lines (209 loc) · 7.55 KB

File metadata and controls

239 lines (209 loc) · 7.55 KB

Command Pattern

A command pattern is the idea to consider an action as an object, so it can be as simple as using a Runnable or a Consumer.

Let's say we have a configuration with several options that can be activated or not depending on the options on the command line, for example, if the option "--long" is present on the command line, the field "longForm" of the configuration should be true.

class Config {
  boolean showHidden = false;
  boolean longForm = false;
  boolean showInode = false;
  boolean showHelp = false;

  @Override
  public String toString() {
    return "Config[showHidden: %s, longForm: %s, showInode: %s, showHelp: %s]"
        .formatted(showHidden, longForm, showInode, showHelp);
  }
}

If we suppose that there is a method config that takes a list of arguments and returns a configuration object, the mainshould be something like this

var config = config(List.of(args));
System.out.println(config);
if (config.showHelp) {
  System.out.println("""
      --all, -a: show hidden files
      --long, -l: long form
      --inode, -i: show inodes
      --help, -h: show this help
      """);
}

A straw-man implementation of the method config can be this one

static Config config(List<String> args) {
  var config = new Config();
  for(var arg: args) {
    switch (arg) {
      case "-a", "--all" -> {
        if (config.showHidden) {
          throw new IllegalStateException("--all specified twice");
        }
        config.showHidden = true;
      }
      case "-l", "--long" -> {
        if (config.longForm) {
          throw new IllegalStateException("--long specified twice");
        }
        config.longForm = true;
      }
      case "-i", "--inode" -> {
        if (config.showInode) {
          throw new IllegalStateException("--inode specified twice");
        }
        config.showInode = true;
      }
      case "-h", "--help" -> {
        if (config.showHelp) {
          throw new IllegalStateException("--help specified twice");
        }
        config.showHelp = true;
      }
      default -> {}  // ignore
    }
  }
  return config;
}

This implementation is not clean, there is a lot of redundancy in the way having twice the same options is handled and if we want to add a new option we also to not forget to update the help description of the main.

Enter the command pattern

The idea is to see an action, here changing on field of the configuration from false to true as an object. Here, our record Command also store a name to be used to detect if several options correspond to the same command on the command line.

classDiagram
class CommandRegistry {
  registerOptions(List~String~ options, String description, Config -> void action)
  command(String option) Command
  help() String
}
class Command {
  <<record>>
  String name
  Config -> void action
}
CommandRegistry --> "0..*" Command
Loading

The CommandRegitry store the association between an option as a String and the corresponding Command and also the help description.

record Command(String name, Consumer<Config> action) {}

class CommandRegistry {
  private final HashMap<String, Command> map = new HashMap<>();
  private final StringBuilder help = new StringBuilder();

  public void registerOptions(List<String> options, String description, Consumer<Config> action) {
    var command = new Command(options.get(0), action);
    options.forEach(option -> map.put(option, command));
    help.append(String.join(", ", options)).append(": ").append(description).append("\n");
  }

  public Command command(String option) {
    return map.get(option);
  }

  public String help() {
    return help.toString();
  }
}

We need a method to configure the CommandRegistry i.e. register all the commands with their options

static CommandRegistry commandRegistry() {
  var registry = new CommandRegistry();
  registry.registerOptions(List.of("--all", "-a"), "show hidden files", c -> c.showHidden = true);
  registry.registerOptions(List.of("--long", "-l"), "long form", c -> c.longForm = true);
  registry.registerOptions(List.of("--inode", "-i"), "show inodes", c -> c.showInode = true);
  registry.registerOptions(List.of("--help", "-h"), "show this help", c -> c.showHelp = true);
  return registry;
}

And we modify the main a little, to ask for the CommmandRegistry and pass it as parameter of the method config.

var registry = commandRegistry();
var config = config(registry, List.of(args));
System.out.println(config);
if (config.showHelp) {
  System.out.println(registry.help());
}

In the method config, we loop over the argument, find the corresponding command (if it's an option) and call the action of the Command on the Config object. We also check that we don't see a command with the same name twice.

static Config config(CommandRegistry registry, List<String> args) {
  var config = new Config();
  var commandSet = new HashSet<String>();
  for(var arg: args) {
    var command = registry.command(arg);
    if (command == null) {
      continue;  // ignore
    }
    if (!commandSet.add(command.name)) {
      throw new IllegalStateException(command.name + " specified twice");
    }
    command.action.accept(config);
  }
  return config;
}

We can change the code of the CommandRegistry a bit because we can recognize that the method registerOptions and the method command are not called at the same time, so separating them using the builder pattern may make the code easier to use.

Using a builder

The idea is to have a builder to register all the options and to ask for the CommandRegistry once all the options with their corresponding command are registered.

Let's refactor the code (change the code without changing the API) of CommandRegistry

static CommandRegistry commandRegistry() {
  return new CommandRegistry.Builder()
      .registerOptions(List.of("--all", "-a"), "show hidden files", c -> c.showHidden = true)
      .registerOptions(List.of("--long", "-l"), "long form", c -> c.longForm = true)
      .registerOptions(List.of("--inode", "-i"), "show inodes", c -> c.showInode = true)
      .registerOptions(List.of("--help", "-h"), "show this help", c -> c.showHelp = true)
      .toRegistry();
}

Given that the CommandRegitry has no method registerOptions anymore, it's just something that encapsulates the Map of Command and the help description. So it can be modelled by a record like this:

record CommandRegistry(Map<String, Command> commandMap, String help) {
  public static class Builder {
    private final HashMap<String, Command> map = new HashMap<>();
    private final StringBuilder help = new StringBuilder();

    public Builder registerOptions(List<String> options, String description, Consumer<Config> action) {
      var command = new Command(options.get(0), action);
      options.forEach(option -> map.put(option, command));
      help.append(String.join(", ", options)).append(": ").append(description).append("\n");
      return this;
    }

    public CommandRegistry toRegistry() {
      return new CommandRegistry(Map.copyOf(map), help.toString());
    }
  }

  public Command command(String option) {
    return commandMap.get(option);
  }
}
classDiagram
class CommandRegistryBuilder {
  registerOptions(List~String~ options, String description, Config -> void action)
  toRegistry() CommandRegistry
}
class CommandRegistry {
  <<record>>
  String help
  command(String option) Command
}
class Command {
  <<record>>
  String name
  Config -> void action
}
CommandRegistryBuilder ..> CommandRegistry : creates
CommandRegistry --> "1..*" Command
Loading