-
Notifications
You must be signed in to change notification settings - Fork 394
Introduce new command builder DSL #1211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
56200c2 to
597b403
Compare
|
Thank you for the PR!
I agree, the name is confusing (naming is hard..). I recently renamed However, as mentioned in #1209 (reply in thread), we might not need to rename We can still use I think if we redesign
Yes, I am thinking of introducing a new programming model based on
I think of something as simple as: public interface Command {
String getName();
String getDescription();
// other command properties
// a method to define the business logic of the command
// input/output types can be reviewed here, I am thinking of CommandExitCode ?
String execute(String[] args) throws Exception;
}
No need for Specs and Definitions, only Definitions. And that would be only used by the DSL.
Yes, the modifications you introduced are acceptable, and there is no need to maintain backward compatibility since we are designing a major release. |
|
@fmbenhassine, I'd like to thank you very much for such detailed feedback. Let me summarize all the information so far. The A Here’s an example of what a class Command {
private final String name;
public Command(String name) {}
// ...
}It's an object (not an interface?) that would contain all information about the command. The first question: do you think the name conflict between the annotation and the class
So maybe it could look something like this: Command.builder()
.name("name")
.description("desc")
.withOption(option -> …)
.build()The Finally, the user will have two ways to register a command. Through the
|
|
You are welcome! Thank YOU for all your time and effort on this. Some feedback on your input (as I am currently experimenting with all this): Regarding command registry:
Correct. I already changed public class CommandRegistry {
private Set<Command> commands;
// methods like register, unregister, etc
}Note it is a class, not an interface anymore (I really see no need for it to be an interface, KISS!). Regarding commands:
I don't think so neither, so I agree with you and I don't see this as a source of confusion, especially that they live in different packages. You asked about In my opinion, @Bean
public Command hello() {
return new HelloCommand(); // HelloCommand implements Command
}A bit similar to what we do with other projects from the portfolio like batch or integration: @Bean
public Job job() {
return new MyBatchJob(); // MyBatchJob implements Job
}That's the foundational programming model that serves as a base for 1) the annotation model and 2) the DSL.
@Command
String test() {
return "test";
}
Regarding Command execution
They are already two separate types: We have public interface Command {
String getName();
String getDescription();
String getHelp();
String getGroup();
List<CommandOption> getOptions();
List<CommandAlias> getAliases();
/**
* Execute the command within the given context.
* @param commandContext the context of the command
* @return the result of the command execution
*/
CommandExecution execute(CommandContext commandContext);
}Domain types are now defined as records: /**
* Record representing the execution of a command.
*/
public record CommandExecution(LocalDateTime startTime, LocalDateTime endTime, CommandExitCode exitCode) {
}
/**
* Record representing the execution of a command.
*/
public record CommandExitCode(int exitCode, String exitDescription, List<Throwable> errors) {
}
/**
* Record containing information about current command execution.
*/
public record CommandContext(String[] rawArgs, Terminal terminal) {
}Optional, nice to have functional interface to define commands logicWith that in place, and in order to avoid making users implement the entire @FunctionalInterface
public interface CommandXXX { // find a good name
/**
* Execute the command within the given context.
* @param commandContext the context of the command
* @return the result of the command execution
*/
CommandExecution execute(CommandContext commandContext);
}This makes it possible to write something like this with the DSL: @Bean
Command someCommand() {
return Command.builder()
.name("name")
.description("desc")
.withOption(option -> …)
.execute(context -> System.out.println("hello"))
.build();
}What do you think? I find this to be simplistic design, more "natural" than the current way of doing things and consistent with the rest of the portfolio. But I am open to a second thought, I might be missing some details! Another possible design is to define that functional interface as @FunctionalInterface
public interface CommandExecution {
/**
* Execute the command within the given context.
* @param commandContext the context of the command
*/
void execute(CommandContext commandContext) throws Exception;
}The idea here is that since the
I am currently working on the new design of the command definition, registration and discovery (and more deeply, how to configure Spring Shell with I will drop a message here and in other PRs when |
After some refactoring and playing around with the code, I agree.
Haha, sorry — this is just how my refactors go. Are you reading my mind? 🤔 Records feel more natural for simple data holders.
Yes, that syntactic sugar will be super helpful for small tools. |
|
@fmbenhassine you can check how my refactor is going. |
b35b9d6 to
baee81c
Compare
|
It is going in the right direction! However, I am in the middle of a more aggressive refactoring, so apologies upfront but you will have to rebase your PR 😔 By aggressive I mean a lot of big breaking changes: removal of In this PR, I find @FunctionalInterface
public interface Command {
/**
* Get the name of the command.
* @return the name of the command
*/
default String getName() {
return this.getClass().getSimpleName().toLowerCase();
}
/**
* Get a short description of the command.
* @return the description of the command
*/
default String getDescription() {
return "";
}
/**
* Get the help text of the command.
* @return the help text of the command
*/
default String getHelp() {
// TODO generate default help from description, options, aliases, etc.
return "";
}
/**
* Get the group of the command.
* @return the group of the command
*/
default String getGroup() {
return "";
}
/**
* Get the options of the command.
* @return the options of the command
*/
default List<CommandOption> getOptions() {
return Collections.emptyList();
}
/**
* Get the aliases of the command.
* @return the aliases of the command
*/
default List<CommandAlias> getAliases() {
return Collections.emptyList();
}
// TODO should command availability be defined here on in the CommandContext (some commands should be available or not depending on the context)
/**
* Execute the command within the given context.
* @param commandContext the context of the command
*/
void execute(CommandContext commandContext) throws Exception;
}Unfortunately, it will be very hard to do this in an incremental way with distinct "clean" commits that build correctly (currently everything is tied together), so I will make the exception and push changes to Keep tuned! |
Okay, then I’ll wait for those.
Baby steps — first, make a working example with the basic stuff, and also gather some feedback.
I'm not quite sure it's the right place, but maybe I don't fully understand your intentions. I like the
Hmm, maybe that's a good idea. But are you thinking about defaults for all methods?
Okay, so please give me a heads-up when you're done ✅ |
Sure! I pushed the basic building blocks for the new programming model, see 7199ea4. The hello world sample now runs as expected with the new design described in #1207 (notice the two sample commands, one to showcase the annotation model and the other to showcase the programmatic model). As mentioned previously, the plan is to iterate on this. There are a couple TODOs like options handling, aliases, and command availability. Once that in place, I think we can tackle the new DSL. Spring Boot support was not addressed on purpose until we have a stable foundation in the My goal for now is to gather feedback on this (even though I think we already discussed several points and your feedback was invaluable!!). I find the new model more intuitive and easier to think about than the previous one, but I am looking forward to your feedback on that.
I don't see the relationship in design with
Not all methods, but those that have sensible defaults. The goal is to minimize the API surface, and making |
If I understand you correctly, you won't start implementing the DSL right now?
Definitely! The new model is much smoother. However, I didn't notice any What about parts of the existing features like Are we intentionally resigning from that?
Very good argument, thanks! So what are the next steps? Should I do something with the DSL? If so, it could be done this way (or how do you imagine it?): Command.builder()
.name("say-hello")
.description("Command for saing hello")
.group("greetings")
.help("This is a help string of say-hello command")
// Multiple calls
.withOption(optionSpec -> optionSpec
.longName("name")
.shortName('s')
.description("Say this name")
.type(ResolvableType) // see #1216
.type(Type)
.required(boolean)
.required() // default 'true'
.defaultValue("John")
.position(int)
.arity(OptionArity)
.arity(int, int)
)
// Multiple calls, define multiple aliases (?)
.withAlias(aliasSpec -> aliasSpec
.command("say-hello-alias")
.group("greetings")
)
.build()That should be all for the first iteration, right? |
dc13c32 to
77fc68c
Compare
|
Can you please rebase this on the latest So in this first iteration of the DSL, please do include only what is currently supported, and we will add other features in next iterations (make it work, then make it better 😉). I target the initial version of the new DSL in the upcoming 4.0 M2. Many thanks upfront! |
What you suggest is great! Options and arguments are available through the command context, so no need to define them in the DSL. For aliases ( @Bean
public Command cmd() {
return Command.builder();
.name("cmd")
.description("A command defined as a bean")
.help("This is a sample command defined as a Spring Bean")
.group("greetings")
.aliases("alias1", "alias2")
.execute(ctx -> {
System.out.println("Executing cmd command");
return ExitStatus.OK;
})
.build();
}You can leverage the |
a51b49b to
c51f3cf
Compare
|
Has this been rebased on the latest Please cc me here when you think this is ready to review (with the minimal change set as explained in my previous comments). Many thanks upfront 🙏 |
|
@fmbenhassine hey Mahmoud 👋 It's not done yet. This week I've had very limited free time, so I only managed to do the rebase yesterday 😭 I still need to do some work here. I hope I’ll be able to finish it next week. |
|
Sure! no problem take your time no rush. Thank you upfront 🙏 |
Signed-off-by: Piotr Olaszewski <piotr.olaszewski@thulium.pl>
c51f3cf to
a14657f
Compare
|
@fmbenhassine I think I have it! One thing I don’t like is this hacking way of creating the command execution logic: .execute(commandContext -> {
String ansiString = new AttributedStringBuilder().append("Good morning ")
.append("Sir", BOLD.foreground(GREEN))
.append("!")
.toAnsi();
Terminal terminal = commandContext.terminal();
terminal.writer().println(ansiString);
terminal.flush();
})I'd prefer to have something like: .execute(commandContext -> "some string")Without all this complicated flushing and using the terminal. But we can address this in the next steps, since it’s not easy to define execution precedence. I mean something like: .execute(commandContext -> {
String ansiString = new AttributedStringBuilder().append("Good morning ")
.append("Sir", BOLD.foreground(GREEN))
.append("!")
.toAnsi();
Terminal terminal = commandContext.terminal();
terminal.writer().println(ansiString);
terminal.flush();
})
.execute(commandContext -> "some string")Which |
|
Great! I will take a look early next week. Just one thing, please remove the Boot related changes from this PR, I will take care of that later.
What you are asking for is already possible with the functional command adapter: https://github.com/spring-projects/spring-shell/blob/main/spring-shell-core/src/main/java/org/springframework/shell/core/commands/adapter/FunctionCommandAdapter.java That's the idea behind this "adapters" package, we can add more adapters if needed.
In the DSL there should be only one call to the |
| return org.springframework.shell.core.command.Command.builder() | ||
| .name("good-morning") | ||
| .description("Say good morning") | ||
| .aliases("greetings") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That should be the group, not an alias, right?
Aliases are not supported yet BTW. They are planned for 4.0 M3.
This is what I was also thinking about, so to be clear: if these builder definitions are OK for you: Builder execute(Consumer<CommandContext> commandExecutor);
Builder execute(Function<CommandContext, String> commandExecutor);we can implement them right now. To prevent using the |
Yes, that would be great!
Exactly, calling Looking forward to reviewing this! |
| * Set the aliases of the command. | ||
| * @return this builder | ||
| */ | ||
| Builder aliases(List<String> aliases); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we have a method accepting aliases as varargs and to keep the API minimalistic, I think there is no need for another one that accepts a list (conversion is always possible on the client side).
| /** | ||
| * Builder for creating command. | ||
| */ | ||
| interface Builder { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think the Builder should be an interface with a default implementation (creating a custom user-defined builder is not very likely). There are of course good reasons to make a concept configurable/swappable by defining it in an interface/contract, but for builders I am not sure we need that (at least for the most typical average usage). For example, the RetryPolicy in Spring Framework defines a builder in the interface, and it is a static inner class and not an interface + default implementation: https://github.com/spring-projects/spring-framework/blob/main/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java.
So to keep things simple, can you please follow the same approach? Thank you upfront.
| /** | ||
| * Build the {@link AbstractCommand}. | ||
| */ | ||
| AbstractCommand build(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AbstractCommand is a leaky abstraction here (look at the import coming from a different package). The builder should return the interface type (or at least a package private type that we can't break from the outside, but since we don't have that, returning the interface type is the way to go).
| .description("Say good morning") | ||
| .group("greetings") | ||
| .help("A command that greets the user with 'Good morning!'") | ||
| .execute(commandContext -> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about using the overloaded method that takes a Function<CommandContext, String> and remove the terminal printing /flushing noise?
That would be a good example to show how to print styled output without dealing with terminal details.
| return abstractCommand; | ||
| } | ||
|
|
||
| private ConsumerCommandAdapter initCommand() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code here is used in a single place, so it could be inlined.
Thank you for the updates! LGTM now 👍 I added a couple minor suggestions. If you agree, please update the PR accordingly and it should be good to merge. I can also take care of these changes on merge if you want. |
Below is a new, improved DSL that addresses #1127. You’ll find the usage of individual methods (all of them are listed) to make it easier to review everything clearly.
Important
Since command registration is the heart of Spring Shell, it's very important to carefully review the contracts of the introduced changes. Modifying this API in the future may be problematic, so we need to be sure that this structure is acceptable, as it may stay with us for a long time.
However, when I look at the resulting code — and the existing code in the codebase — I'm not entirely satisfied. The code feels quite clumsy. 😭
I have a few topics I'd like to bring up.
Is the naming of these objects good?
I see an analogy here to
BeanDefinitionandBeanDefinitionRegistry. Right now, we have the nameCommandRegistration. But what exactly is this mysteriousRegistration? I have the impression that it's not a good name. It’s not very descriptive and might be misleading.Maybe we should approach it similarly to the Spring Framework itself and have definitions and registries for commands?
In the definition, we could have a command object with all its metadata — such as names, aliases, and options.
Should we use interfaces?
I'm not sure what the benefit is of having
CommandRegistrationas an interface (and the rest of objects too). I don't really feel that this approach fits well.If we had multiple implementations (
1..n) of differentCommandRegistrations, then an interface might make sense. Here, it seems unnecessary.Another question, how should the objects that hold command definition information look?
Should we use the
Specobjects everywhere, or only during the build phase — where the result would be an object holder?For example:
OptionSpeccould create anOptionDefinitionobject (which could be a simple record).Aggressiveness of changes
I'm also not sure how far-reaching these changes should be. We're changing the baseline — but are the modifications I’ve introduced acceptable? Could we possibly discuss further, larger changes? Do we need to maintain backward compatibility?
Summary
Overall, this image perfectly captures how I feel when walking through the classes and trying to understand how commands are created.
Caution
Without good and clear manual command registration, it will be very difficult to build reliable automatic detection and registration later on.
I'd be happy 🙏 to continue driving this topic, but I need answers to the above questions so I don't go down a dead-end path — and to know how far I can go with the refactor.