Spring Shell

Spring Shell是Spring生态下的一款交互式命令行框架,它能让我们非常方便地基于SpringBoot构建一个功能完善的CLI(命令行交互)应用。借助Spring Shell,我们无需从零处理命令解析、参数绑定、Tab补全、帮助信息等繁琐细节,只需编写带有注解的普通Java方法,框架就会自动将其注册为可交互的命令。这篇笔记我们以SpringBoot 3.x为基础,介绍Spring Shell的使用。

引入Maven依赖

Spring Shell并不在SpringBoot的默认Dependency Management中维护,我们需要单独引入,首先我们需要在<properties>中添加使用的Spring Shell版本。

<properties>
    <spring-shell.version>3.4.2</spring-shell.version>
</properties>

然后引入Spring Shell官方的BOM统一管控Spring Shell全家桶所有依赖的版本。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.shell</groupId>
            <artifactId>spring-shell-dependencies</artifactId>
            <version>${spring-shell.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

最后我们引入Spring Shell的核心运行依赖和单元测试支持。

<dependency>
    <groupId>org.springframework.shell</groupId>
    <artifactId>spring-shell-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.shell</groupId>
    <artifactId>spring-shell-starter-test</artifactId>
    <scope>test</scope>
</dependency>

SpringBoot工程配置和三种工作模式

application.properties中,我们需要添加以下配置。

spring.shell.interactive.enabled=true
spring.shell.noninteractive.enabled=false
spring.shell.script.enabled=false

interactive.enabled:是否开启交互CLI模式,默认为false。所谓交互模式其实就是一个交互式REPL(读 - 求值 - 输出循环),开启后,应用启动完成会开启一个非守护线程持续监听终端输入,保持JVM进程存活,不会自动退出,我们可以在终端中输入各种命令并查看应用的响应。交互模式下应用启动后我们会看到终端输出类似下面的内容,shell:>是意思就是等待我们输入命令。

noninteractive.enabled:是否开启非交互模式,默认为true。非交互模式支持启动应用时直接传入Shell命令,执行完成后自动退出进程,不需要人工交互。下面例子命令我们直接执行hello命令,执行完应用退出。

java -jar demo-spring-shell.jar hello

script.enabled:控制是否启用脚本批量执行模式,默认为false。开启后,Spring Shell启动时会检测前缀为@的启动参数并将其识别为脚本文件路径,逐行读取文件中的命令并批量执行,全部执行完成后进程立即退出,不会进入交互模式。一个实际运行示例如下。

java -jar demo-spring-shell.jar @commands.txt

对应的脚本文件commands.txt中,每行一条Shell命令。

hello
version
info

编写命令

Spring Shell框架中,命令是一个基础的执行单元,在代码中它对应一个标注了@ShellMethod注解的方法,拥有命令方法的类需要标注@ShellComponent注解注册到Spring容器中。下面例子中,我们创建一个叫hello的命令,执行它会立刻在控制台打印字符串Hello, Spring Shell!

package com.gacfox.demo.commands;

import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;

@ShellComponent
public class HelloCommands {
    @ShellMethod(key = "hello", value = "打招呼命令")
    public String hello() {
        return "Hello, Spring Shell!";
    }
}

@ShellMethod注解中,key指定命令名,如果省略了key属性,Spring Shell会自动将方法名的驼峰式转换为短横线风格,例如sayHello会自动映射为say-hellovalue是该命令的描述信息,它会显示在help帮助信息中。

命令参数解析

基本参数

Spring Shell会自动将方法参数映射为命令参数,参数名默认以--前缀传入。

package com.gacfox.demo.commands;

import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;

@ShellComponent
public class HelloCommands {
    @ShellMethod(key = "greet", value = "问候某人")
    public String greet(String name, int times) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < times; i++) {
            sb.append("Hello, ").append(name).append("!\n");
        }
        return sb.toString().trim();
    }
}

调用时需要指定对应参数名。

shell:> greet --name Alice --times 3
Hello, Alice!
Hello, Alice!
Hello, Alice!

Spring Shell支持Stringintbooleanlongdouble等常见Java类型,框架会自动完成字符串到目标类型的转换。

参数注解

如果需要为参数指定别名、设为可选、指定默认值等,可以在方法参数上使用@ShellOption注解。

package com.gacfox.demo.commands;

import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;

@ShellComponent
public class HelloCommands {
    @ShellMethod(key = "greet", value = "问候某人")
    public String greet(
            @ShellOption(value = {"-n", "--name"}, defaultValue = "World") String name,
            @ShellOption(value = {"-t", "--times"}, defaultValue = "1") int times) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < times; i++) {
            sb.append("Hello, ").append(name).append("!\n");
        }
        return sb.toString().trim();
    }
}
  • value:指定参数的别名
  • defaultValue:参数的默认值,设置后该参数会变为可选参数

调用效果如下。

shell:> greet -n Alice -t 2
Hello, Alice!
Hello, Alice!

也可以不带任何参数,使用默认值。

shell:> greet
Hello, World!

布尔开关参数

对于boolean类型参数,Spring Shell将其处理为开关,命令行中出现该参数名就代表true,不出现则为false

package com.gacfox.demo.commands;

import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;

@ShellComponent
public class HelloCommands {
    @ShellMethod(key = "say", value = "说话")
    public String say(String message,
                      @ShellOption(defaultValue = "false") boolean shout) {
        return shout ? message.toUpperCase() : message;
    }
}
shell:> say --message "hello world"
hello world

shell:> say --message "hello world" --shout
HELLO WORLD

命令分组

我们可以通过@ShellCommandGroup注解对命令进行分组,它能使help输出更加整洁。

package com.gacfox.demo.commands;

import org.springframework.shell.standard.ShellCommandGroup;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;

@ShellComponent
@ShellCommandGroup("用户管理")
public class UserCommands {
    @ShellMethod(key = "user-list", value = "列出所有用户")
    public String userList() {
        return "用户列表:Alice, Bob, Charlie";
    }

    @ShellMethod(key = "user-add", value = "添加用户")
    public String userAdd(String username) {
        return "用户 " + username + " 已添加";
    }
}

执行help后,命令会按组归类显示。

输入校验

Spring Shell支持集成Bean Validation,我们可以直接在命令参数上使用标准数据验证注解,框架会在执行命令前自动完成参数校验。这需要引入相关的起步依赖。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

然后在命令类上添加 @Validated,并在参数上使用校验注解。

package com.gacfox.demo.commands;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.validation.annotation.Validated;

@ShellComponent
@Validated
public class UserCommands {
    @ShellMethod(key = "user-add", value = "添加用户")
    public String userAdd(
            @NotBlank(message = "用户名不能为空")
            @Size(min = 2, max = 20, message = "用户名长度需在2到20个字符之间")
            String username) {
        return "用户 " + username + " 已添加";
    }
}

当输入不满足校验条件时,Spring Shell会自动打印错误信息并阻止命令执行。

shell:> user-add --username A
用户名长度需在2到20个字符之间

命令返回值与输出

返回字符串

最简单的输出方式是直接返回String,Spring Shell会将其打印到控制台,前面我们用的一直都是这种方式。

package com.gacfox.demo.commands;

import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;

@ShellComponent
public class HelloCommands {
    @ShellMethod(key = "ping", value = "测试连通性")
    public String ping() {
        return "pong";
    }
}

使用Terminal直接写入

如果需要更灵活的输出控制(例如分段输出、格式化着色等),我们可以注入Spring Shell提供的Terminal对象,通过它直接写入内容到终端。

package com.gacfox.demo.commands;

import org.jline.terminal.Terminal;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;

import java.io.PrintWriter;

@ShellComponent
public class OutputCommands {
    private final Terminal terminal;

    @Autowired
    public OutputCommands(Terminal terminal) {
        this.terminal = terminal;
    }

    @ShellMethod(key = "progress", value = "模拟进度输出")
    public void progress() throws InterruptedException {
        PrintWriter writer = terminal.writer();
        for (int i = 1; i <= 5; i++) {
            writer.println("处理中... " + (i * 20) + "%");
            writer.flush();
            Thread.sleep(500);
        }
        writer.println("完成!");
        writer.flush();
    }
}

彩色输出

Spring Shell基于JLine实现终端交互,通过AttributedString我们可以为输出内容添加颜色和样式。

package com.gacfox.demo.commands;

import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;

@ShellComponent
public class OutputCommands {
    @ShellMethod(key = "status", value = "查看状态")
    public AttributedString status() {
        AttributedStringBuilder builder = new AttributedStringBuilder();
        builder.append("服务状态:");
        builder.styled(
                AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN),
                "运行中"
        );
        return builder.toAttributedString();
    }
}

执行效果如下。

交互式输入

有些命令需要在执行过程中向用户提问,收集输入后再继续,Spring Shell提供了LineReader可以实现这个功能。

package com.gacfox.demo.commands;

import org.jline.reader.LineReader;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;

@ShellComponent
public class InteractiveCommands {
    private final LineReader lineReader;

    @Autowired
    public InteractiveCommands(LineReader lineReader) {
        this.lineReader = lineReader;
    }

    @ShellMethod(key = "create-user", value = "交互式创建用户")
    public String createUser() {
        String username = lineReader.readLine("请输入用户名:");
        String email = lineReader.readLine("请输入邮箱:");
        return String.format("用户已创建:%s <%s>", username, email);
    }
}
shell:> create-user
请输入用户名:Alice
请输入邮箱:alice@example.com
用户已创建:Alice <alice@example.com>

对于密码等敏感输入,我们可以使用readLine()的掩码参数,输入时字符会被*遮挡,例子如下。

String password = lineReader.readLine("请输入密码:", '*');

自定义命令补全

Spring Shell默认支持命令名和参数名的Tab补全,如果命令参数的值域是固定的(例如是固定枚举值或取数据库中的某些记录),我们还可以自定义值补全逻辑。代码中,Spring Shell使用CompletionContextCompletionProposal来描述补全行为,下面是一个例子。

package com.gacfox.demo.completion;

import org.springframework.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
import org.springframework.shell.standard.ValueProvider;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class EnvValueProvider implements ValueProvider {
    @Override
    public List<CompletionProposal> complete(CompletionContext completionContext) {
        List<String> values = Arrays.asList("dev", "test", "prod");

        return values.stream()
                .map(CompletionProposal::new)
                .collect(Collectors.toList());
    }
}

然后在命令参数上通过@ShellOptioncompletionProvider属性关联这个Provider。

package com.gacfox.demo.commands;

import com.gacfox.demo.completion.EnvValueProvider;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;

@ShellComponent
public class DeployCommands {
    @ShellMethod(key = "deploy", value = "部署到指定环境")
    public String deploy(
            @ShellOption(valueProvider = EnvValueProvider.class) String env) {
        return "正在部署到 " + env + " 环境...";
    }
}

这样在输入deploy --env后按Tab键,就会自动提示devtestprod选项,效果如下图。

异常处理

命令方法执行时若抛出异常,Spring Shell默认会将异常信息打印出来。如果我们希望对某些已知异常进行友好化提示而不是输出一大段堆栈,可以实现CommandExceptionResolver接口,统一拦截处理命令执行时的异常。

package com.gacfox.demo.exception;

import org.springframework.shell.command.CommandExceptionResolver;
import org.springframework.shell.command.CommandHandlingResult;
import org.springframework.stereotype.Component;

@Component
public class GlobalCommandExceptionResolver implements CommandExceptionResolver {
    @Override
    public CommandHandlingResult resolve(Exception e) {
        if (e instanceof IllegalArgumentException) {
            return CommandHandlingResult.of("参数错误:" + e.getMessage() + "\n");
        }
        return CommandHandlingResult.of("命令执行失败:" + e.getMessage() + "\n");
    }
}

CommandHandlingResult.of()接受错误提示字符串,该信息会被直接打印到终端,同时命令执行被视为失败结束。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。