4

I'm trying to use Picocli with Spring Boot 2.2 to pass command line parameters to a Spring Bean, but not sure how to structure this. For example, I have the following @Command to specify a connection username and password from the command line, however, want to use those params to define a Bean:

@Component
@CommandLine.Command
public class ClearJdoCommand extends HelpAwarePicocliCommand {
    @CommandLine.Option(names={"-u", "--username"}, description = "Username to connect to MQ")
    String username;

    @CommandLine.Option(names={"-p", "--password"}, description = "Password to connect to MQ")
    String password;

    @Autowired
    JMSMessagePublisherBean jmsMessagePublisher;

    @Override
    public void run() {
        super.run();
        jmsMessagePublisher.publishMessage( "Test Message");
    }
}


@Configuration
public class Config {
    @Bean
    public InitialContext getJndiContext() throws NamingException {
        // Set up the namingContext for the JNDI lookup
        final Properties env = new Properties();
        env.put(Context.INITIAL_CONTEXT_FACTORY, INITIAL_CONTEXT_FACTORY);
        env.put(Context.PROVIDER_URL, "http-remoting://localhost:8080");
        env.put(Context.SECURITY_PRINCIPAL, username);
        env.put(Context.SECURITY_CREDENTIALS, password);
        return new InitialContext(env);
    }

    @Bean
    public JMSPublisherBean getJmsPublisher(InitialContext ctx){
        return new JMSPublisherBean(ctx);
    }
}

I'm stuck in a bit of a circular loop here. I need the command-line username/password to instantiate my JMSPublisherBean, but these are only available at runtime and not available at startup.

I have managed to get around the issue by using Lazy intialization, injecting the ClearJdoCommand bean into the Configuration bean and retrieving the JMSPublisherBean in my run() from the Spring context, but that seems like an ugly hack. Additionally, it forces all my beans to be Lazy, which is not my preference.

Is there another/better approach to accomplish this?

Community
  • 1
  • 1
Eric B.
  • 23,425
  • 50
  • 169
  • 316
  • This doesn’t answer your question, but note that the kakawait plugin has been superseded with https://github.com/remkop/picocli/tree/master/picocli-spring-boot-starter – Remko Popma Nov 20 '19 at 12:34

2 Answers2

3

Second option might be to use pure PicoCli (not PicoCli spring boot starter) and let it run command; command will not be Spring bean and will only be used to validate parameters.

In its call method, Command would create SpringApplication, populate it with properties (via setDefaultProperties or using JVM System.setProperty - difference is that environment variables will overwrite default properties while system properties have higher priority).

  @Override
  public Integer call() {
    var application = new SpringApplication(MySpringConfiguration.class);
    application.setBannerMode(Mode.OFF);
    System.setProperty("my.property.first", propertyFirst);
    System.setProperty("my.property.second", propertySecond);
    try (var context = application.run()) {
      var myBean = context.getBean(MyBean.class);
      myBean.run(propertyThird);
    }
    return 0;
  }

This way, PicoCli will validate input, provide help etc. but you can control configuration of Spring Boot application. You can even use different Spring configurations for different commands. I believe this approach is more natural then passing all properties to CommandLineRunner in Spring container

1

One idea that may be useful is to parse the command line in 2 passes:

  1. the first pass is just to pick up the information needed for configuration/initialization
  2. in the second pass we pick up additional options and execute the application

To implement this, I would create a separate class that "duplicates" the options that are needed for configuration. This class would have an @Unmatched field for the remaining args, so they are ignored by picocli. For example:

class Security {
    @Option(names={"-u", "--username"})
    static String username;

    @Option(names={"-p", "--password"}, interactive = true, arity = "0..1")
    static String password;

    @Unmatched List<String> ignored;
}

In the first pass, we just want to extract the username/password info, we don't want to execute the application just yet. We can use the CommandLine.parseArgs or CommandLine.populateCommand methods for that.

So, our main method can look something like this:

public static void main(String[] args) throws Exception {

  // use either populateCommand or parseArgs
  Security security = CommandLine.populateCommand(new Security(), args); 
  if (security.username == null || security.password == null) {
      System.err.println("Missing required user name or password");
      new CommandLine(new ClearJdoCommand()).usage(System.err);
      System.exit(CommandLine.ExitCode.USAGE);
  }

  // remainder of your normal main method here, something like this?
  System.exit(SpringApplication.exit(SpringApplication.run(MySpringApp.class, args)));
}

I would still keep (duplicate) the usage and password options in the ClearJdoCommand class, so the application can print a nice usage help message when needed.

Note that I made the fields in the Security class static. This is a workaround (hack?) that allows us to pass information to the getJndiContext method.

@Bean
public InitialContext getJndiContext() throws NamingException {
    // Set up the namingContext for the JNDI lookup
    final Properties env = new Properties();
    env.put(Context.INITIAL_CONTEXT_FACTORY, INITIAL_CONTEXT_FACTORY);
    env.put(Context.PROVIDER_URL, "http-remoting://localhost:8080");
    env.put(Context.SECURITY_PRINCIPAL, Security.username); // use info from 1st pass
    env.put(Context.SECURITY_CREDENTIALS, Security.password);
    return new InitialContext(env);
}

There is probably a better way to pass information to this method. Any Spring experts willing to jump in and show us a nicer alternative?

Remko Popma
  • 35,130
  • 11
  • 92
  • 114