Choose which methods to run, with user input

Viewed 144

I have a list of methods within my class. And then want to have input string array, where the user can choose which methods they want to run. We are running expensive insurance calculations. And have over say eg 20 methods. Is there a way to conduct this without do an if check on each? maybe with reflection or interface?

@Override
public void ProductTest(ProductData productData, String[] methodNames)  {
  
    public void methodA(ProductData productData){...};
    public void methodB(ProductData productData){...};
    public void methodC(ProductData productData){...};
    public void methodD(ProductData productData){...};
    public void methodE(ProductData productData){...};
}

I am willing to change the Array into a different ObjectType if needed, to execute properly. Using SpringBoot, has it has a library of utility classes.

4 Answers

Use a Map<String, Consumer<ProductData>>, not separate method handles. Main reason - reflection is slow and dangerous when given user "input"

Use map.get(input).accept(product) to call it.

https://docs.oracle.com/javase/8/docs/api/index.html?java/util/function/Consumer.html

Example

Map<String, Consumer<ProductData>> map = new HashMap<>();

map.put("print_it", System.out::println);
map.put("print_id", data -> System.out.println(data.id));
map.put("id_to_hex", data -> {
  int id = data.getId();
  System.out.printf("0x%x%n", id);
});

ProductData data = new ProductData(16);
map.get("print_it").accept(data);
map.get("print_id").accept(data);
map.get("id_to_hex").accept(data);

Outputs

ProductData(id=16)
16
0x10

If you are planning on chaining consumers using andThen, you'd be better having an Optional<ProductData>, and using a Function<ProductData, ProductData> with Optional.map()

One way to do it is via reflection. You can iterate over methods in the class object and look for ones to run by name. Here's some example code--this would print out a list of names the user could type in:

myObject.getClass().getDeclaredMethods().each((method)->System.out.println(method.getName()))

And this is how you would call it once the user had made a selection:

productTest.getDeclaredMethods().each((method)->
    if(method.getName().equals(userSelectedName))
        method.invoke(productTest, productData)
)

The ONLY advantage to this approach is that you don't have to maintain a second structure (Switch, Map, etc...) and add to it every time you add a new method. A personality quirk makes me unwilling to do that (If adding something one place forces you to update a second, you're doing it wrong), but this doesn't bother everyone as much as it bothers me.

This isn't dangerous or anything, if you don't have a method in the class it can't call it, but if you are relying on users "Typing", I'd suggest listing out the options and allowing a numeric selection--or using reflection to build a map like OneCricketeer's.

I've used this pattern to write a testing language and fixture to test set-top TV boxes, it was super simple to parse a group of strings, map some to methods and other to parameters and have a very flexible, easily extensible testing language.

The method object also has a "getAnnotation()" which can be used to allow more flexible matching in the future.

You can use method invocation.

For example, you can have two methods, first one will loop through your methodNames array and call the second method:

public void callPassedMethods(ProductData productData, String[] methodNames) {
   for (String m : methodNames) {
      callMethod(productData, m)
   }
}

And the second method will actually find a method in your class that matches the string passed and invoke it:

public void callMethod(ProductData productData, String methodName) {
   try {
      ClassName yourObj = new ClassName(); // Class where your methods are
      Method method = yourObj.getClass().getDeclaredMethod(methodName, ProductData.class);
      method.invoke(yourObj, productData);
   } catch(NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
      // handle exceptions
   }
}

Or, you can always use the good old switch statement:

 for (String m : methodNames) {

    switch (m) {
      case "methodA":
         methodA();
         break;
      case "methodB":
         methodB();
         break;
      // ... continue with as many cases as you need
    }
 }

If you go with the reflection route, you don't really want to expose your method names to the end users. They might not be end user-friendly, and if they are, there is no reason for users to know this information and there might be methods, which are not supposed to be invoked by users. I would use custom annotations to build more flexible matching.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UserChoice {

  String userFriendlyOption();
  int optionNumber();
}

optionNumber will be used for matching the method to invoke, userFriendlyOption is some user friendly text.

Annotate only the methods, supposed to be used by users.

@RequiredArgsConstructor
public class ProductData {

  private final double data;

  @UserChoice(userFriendlyOption = "see result for option a", optionNumber = 1)
  public void methodA() {
    System.out.println(data + 1);
  }

  @UserChoice(userFriendlyOption = "see result for option b", optionNumber = 2)
  public void methodB() {
    System.out.println(data + 2);
  }

  @UserChoice(userFriendlyOption = "see result for option c", optionNumber = 3)
  public void methodC() {
    System.out.println(data);
  }

  public void methodNotForUser() {
    System.out.println("Should not be seen by users");
  }
}

Like this methodNotForUser() can't be invoked by end users.

Simplified matcher might look like this.

@RequiredArgsConstructor
public class ProductTester {

  private final ProductData data;
  private Map<Integer, MethodData> map;

  public void showOptions() {
    if (this.map == null) {
      this.map = new HashMap<>();
      for (Method method : this.data.getClass().getMethods()) {
        UserChoice userChoice = method.getAnnotation(UserChoice.class);
        if (userChoice != null) {
          String userRepresentation = userChoice.optionNumber() + " - " + userChoice.userFriendlyOption();
          this.map.put(userChoice.optionNumber(), new MethodData(userRepresentation, method));
        }
      }
    }
    this.map.entrySet().stream()
            .sorted(Map.Entry.comparingByKey())
            .forEach(entry -> System.out.println(entry.getValue().getUserRepresentation()));
  }

  public void showOptionResult(int choice) {
    MethodData methodData = this.map.get(choice);
    if (methodData == null) {
      System.out.println("Invalid choice");
      return;
    }
    System.out.println("Result");
    try {
      methodData.getMethod().invoke(this.data);
    } catch (IllegalAccessException | InvocationTargetException ignore) {
      //should not happen
    }
  }
}

MethodData is simple pojo with the sole purpose to not recalculate user representation.

@RequiredArgsConstructor
@Getter
public class MethodData {

  private final String userRepresentation;
  private final Method method;
}

Short main to illustrate the idea and play around:

public class Temp {

  public static void main(String[] args) {
    Scanner scanner = new Scanner(System.in);
    System.out.println("Write initial value");
    double data = scanner.nextDouble();
    ProductData myData = new ProductData(data);
    ProductTester tester = new ProductTester(myData);
    tester.showOptions();
    System.out.println("Write option number");
    int userChoice = scanner.nextInt();
    tester.showOptionResult(userChoice);
  }
}
Related