Spring Shell命令行工具开发实战

1 阅读6分钟

Spring Shell是Spring生态系统中的一个强大组件,它允许开发者轻松创建功能丰富的命令行应用程序。

当与Spring Boot结合使用时,可以快速构建出专业级别的CLI工具。

本文将介绍如何在Spring Boot项目中集成和使用Spring Shell,开发自定义命令,以及一些高级特性。

1. Spring Shell简介

Spring Shell是一个交互式shell框架,它提供了一种通过命令行与应用程序交互的方式。

它支持自动补全、帮助文档生成、命令历史和各种交互式功能,使命令行工具更加用户友好。

Spring Shell的主要特性包括:

  • 类似于Bash的交互体验
  • Tab键自动补全功能
  • 内置帮助系统
  • 命令历史记录
  • 参数验证和转换
  • 命令分组和可扩展性

2. 在SpringBoot项目中集成Spring Shell

添加依赖

首先,在您的Spring Boot项目的pom.xml中添加Spring Shell依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.5</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>demo</groupId>
    <artifactId>springboot-cmd</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.shell</groupId>
            <artifactId>spring-shell-starter</artifactId>
            <version>3.4.0</version>
        </dependency>

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.18</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.32</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>21</source>
                    <target>21</target>
                    <encoding>utf-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>3.2.0</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

基本配置


@SpringBootApplication
public class ShellToolApplication {
    public static void main(String[] args) {
        SpringApplication application = new SpringApplication(ShellToolApplication.class);
        application.setBannerMode(Banner.Mode.OFF);
        application.run(args);
    }

    @Bean
    public PromptProvider shellPromptProvider() {
        return () -> new AttributedString("boot-shell:>", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW));
    }

}
spring:
  shell:
    interactive:
      enabled: true

3. 开发自定义命令

在Spring Shell中,命令是通过在标有@ShellComponent注解的类中创建标有@ShellMethod注解的方法来定义的。

创建基本命令

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

@ShellComponent
public class MyCommands {
    
    @ShellMethod("显示欢迎消息")
    public String hello(String name) {
        return "你好, " + name + "!";
    }
    
    @ShellMethod("计算两个数字的和")
    public int add(int a, int b) {
        return a + b;
    }

    // 注意: 如果方法名为驼峰式命名,则shell使用时需要使用-分隔符,如addBig -> add-big
    @ShellMethod(value = "计算两个数字的和,注意大小写")
    public int addBig(int a, int b) {
        return a + b;
    }

    // 关于驼峰命名也可以使用key属性进行指定,则shell使用时仍然是驼峰式命名,如addSmall -> addSmall
    @ShellMethod(value = "计算两个数字的和,注意大小写",key = {"addSmall"})
    public int addSmall(int a, int b) {
        return a + b;
    }
}

使用上面的代码,当您运行应用程序时,将可以使用helloadd命令:

shell:>hello 世界
你好, 世界!
shell:>add 5 3
8

命令参数配置

Spring Shell提供了丰富的参数配置选项:

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

@ShellComponent
public class AdvancedCommands {
    
    @ShellMethod("使用高级参数选项的示例")
    public String greet(
        @ShellOption(defaultValue = "世界") String name,
        @ShellOption(help = "决定是否使用大写", defaultValue = "false") boolean uppercase
    ) {
        String greeting = "你好, " + name + "!";
        return uppercase ? greeting.toUpperCase() : greeting;
    }
}

4. 高级功能

命令分组

您可以使用@ShellCommandGroup注解对命令进行分组:

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

@ShellComponent
@ShellCommandGroup("文件操作")
public class FileCommands {
    
    @ShellMethod("列出目录内容")
    public String ls(String path) {
        // 实现列出目录内容的逻辑
        return "列出 " + path + " 的内容";
    }
    
    @ShellMethod("创建新目录")
    public String mkdir(String dirName) {
        // 实现创建目录的逻辑
        return "创建目录: " + dirName;
    }
}

命令可用性控制

可以使用Availability来控制命令在不同情况下的可用性:

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

@ShellComponent
public class SecurityCommands {
    
    private boolean loggedIn = false;
    
    @ShellMethod("登录系统")
    public String login(String username, String password) {
        // 实现登录逻辑
        this.loggedIn = true;
        return "用户 " + username + " 已登录";
    }
    
    @ShellMethod("查看敏感信息")
    public String query() {
        return "这是敏感信息,只有登录后才能查看";
    }
    
    @ShellMethodAvailability("query")
    public Availability viewSecretInfoAvailability() {
        return loggedIn
            ? Availability.available()
            : Availability.unavailable("您需要先登录才能查看敏感信息");
    }
}

自定义提示符

您可以通过实现PromptProvider接口来自定义提示符:

@Bean
public PromptProvider shellPromptProvider() {
    return () -> new AttributedString("my-shell:>", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW));
}

5. 一个工具示例

下面是一个更完整的示例,展示如何创建一个简单的HTTP访问CLI工具

package com.example.cmd.shell;

import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONUtil;
import org.springframework.shell.standard.ShellCommandGroup;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@ShellComponent
@ShellCommandGroup("HTTP请求")
public class HttpClientCommands {
    
    private String baseUrl = "";
    private final Map<String, String> headers = new HashMap<>();
    private final List<String> requestHistory = new ArrayList<>();
    
    @ShellMethod("设置基础URL")
    public String setBaseUrl(String url) {
        this.baseUrl = url;
        return "基础URL已设置为: " + url;
    }
    
    @ShellMethod("添加HTTP请求头")
    public String addHeader(String name, String value) {
        headers.put(name, value);
        return "已添加请求头: " + name + " = " + value;
    }
    
    @ShellMethod("清除所有HTTP请求头")
    public String clearHeaders() {
        int count = headers.size();
        headers.clear();
        return "已清除 " + count + " 个请求头";
    }
    
    @ShellMethod("显示当前配置")
    public String showConfig() {
        StringBuilder sb = new StringBuilder();
        sb.append("基础URL: ").append(baseUrl).append("\n");
        sb.append("请求头:\n");
        
        if (headers.isEmpty()) {
            sb.append("  (无)\n");
        } else {
            headers.forEach((name, value) -> 
                sb.append("  ").append(name).append(": ").append(value).append("\n"));
        }
        
        return sb.toString();
    }
    
    @ShellMethod("发送GET请求")
    public String get(
        @ShellOption(help = "请求的路径或完整URL") String path,
        @ShellOption(help = "是否显示响应头", defaultValue = "false") boolean showHeaders
    ) {
        String url = buildUrl(path);
        requestHistory.add("GET " + url);
        
        try {
            HttpRequest request = HttpRequest.get(url);
            addHeadersToRequest(request);
            
            HttpResponse response = request.execute();
            return formatResponse(response, showHeaders);
        } catch (Exception e) {
            return "请求失败: " + e.getMessage();
        }
    }
    
    @ShellMethod("发送POST请求")
    public String post(
        @ShellOption(help = "请求的路径或完整URL") String path,
        @ShellOption(help = "POST请求体(JSON)") String body,
        @ShellOption(help = "是否显示响应头", defaultValue = "false") boolean showHeaders
    ) {
        String url = buildUrl(path);
        requestHistory.add("POST " + url);
        
        try {
            HttpRequest request = HttpRequest.post(url);
            addHeadersToRequest(request);
            
            // 添加Content-Type请求头,如果没有设置
            if (!headers.containsKey("Content-Type")) {
                request.header("Content-Type", "application/json");
            }
            
            HttpResponse response = request.body(body).execute();
            return formatResponse(response, showHeaders);
        } catch (Exception e) {
            return "请求失败: " + e.getMessage();
        }
    }
    
    @ShellMethod("发送PUT请求")
    public String put(
        @ShellOption(help = "请求的路径或完整URL") String path,
        @ShellOption(help = "PUT请求体(JSON)") String body,
        @ShellOption(help = "是否显示响应头", defaultValue = "false") boolean showHeaders
    ) {
        String url = buildUrl(path);
        requestHistory.add("PUT " + url);
        
        try {
            HttpRequest request = HttpRequest.put(url);
            addHeadersToRequest(request);
            
            // 添加Content-Type请求头,如果没有设置
            if (!headers.containsKey("Content-Type")) {
                request.header("Content-Type", "application/json");
            }
            
            HttpResponse response = request.body(body).execute();
            return formatResponse(response, showHeaders);
        } catch (Exception e) {
            return "请求失败: " + e.getMessage();
        }
    }
    
    @ShellMethod("发送DELETE请求")
    public String delete(
        @ShellOption(help = "请求的路径或完整URL") String path,
        @ShellOption(help = "是否显示响应头", defaultValue = "false") boolean showHeaders
    ) {
        String url = buildUrl(path);
        requestHistory.add("DELETE " + url);
        
        try {
            HttpRequest request = HttpRequest.delete(url);
            addHeadersToRequest(request);
            
            HttpResponse response = request.execute();
            return formatResponse(response, showHeaders);
        } catch (Exception e) {
            return "请求失败: " + e.getMessage();
        }
    }
    
    @ShellMethod("下载文件")
    public String download(
        @ShellOption(help = "文件URL") String url,
        @ShellOption(help = "保存路径") String savePath
    ) {
        requestHistory.add("DOWNLOAD " + url);
        
        try {
            long fileSize = HttpUtil.downloadFile(url, savePath);
            return "文件下载成功,大小: " + fileSize + " 字节,保存路径: " + savePath;
        } catch (Exception e) {
            return "文件下载失败: " + e.getMessage();
        }
    }
    
    @ShellMethod("上传文件")
    public String upload(
        @ShellOption(help = "上传URL") String url,
        @ShellOption(help = "文件参数名") String paramName,
        @ShellOption(help = "文件路径") String filePath,
        @ShellOption(help = "是否显示响应头", defaultValue = "false") boolean showHeaders
    ) {
        requestHistory.add("UPLOAD " + url);
        
        try {
            HttpRequest request = HttpRequest.post(url);
            addHeadersToRequest(request);
            
            request.form(paramName, new java.io.File(filePath));
            HttpResponse response = request.execute();
            
            return formatResponse(response, showHeaders);
        } catch (Exception e) {
            return "文件上传失败: " + e.getMessage();
        }
    }
    
    @ShellMethod("格式化JSON")
    public String formatJson(
        @ShellOption(help = "JSON字符串") String json
    ) {
        try {
            return JSONUtil.formatJsonStr(json);
        } catch (Exception e) {
            return "JSON格式化失败: " + e.getMessage();
        }
    }
    
    private String buildUrl(String path) {
        if (path.toLowerCase().startsWith("http")) {
            return path;
        }
        
        if (baseUrl.isEmpty()) {
            return path;
        }
        
        if (baseUrl.endsWith("/") && path.startsWith("/")) {
            return baseUrl + path.substring(1);
        } else if (!baseUrl.endsWith("/") && !path.startsWith("/")) {
            return baseUrl + "/" + path;
        } else {
            return baseUrl + path;
        }
    }
    
    private void addHeadersToRequest(HttpRequest request) {
        headers.forEach(request::header);
    }
    
    private String formatResponse(HttpResponse response, boolean showHeaders) {
        StringBuilder sb = new StringBuilder();
        sb.append("状态码: ").append(response.getStatus()).append("\n");
        
        if (showHeaders) {
            sb.append("响应头:\n");
            response.headers().forEach((name, values) -> {
                sb.append("  ").append(name).append(": ");
                sb.append(String.join(", ", values)).append("\n");
            });
            sb.append("\n");
        }
        
        sb.append("响应体:\n");
        
        // 尝试格式化JSON响应体
        String body = response.body();
        if (body != null && !body.isEmpty()) {
            try {
                if (body.trim().startsWith("{") || body.trim().startsWith("[")) {
                    body = JSONUtil.formatJsonStr(body);
                }
            } catch (Exception ignored) {
                // 如果不是有效的JSON,直接使用原始响应体
            }
        }
        
        sb.append(body);
        
        return sb.toString();
    }
}

6. 内置命令清单

Spring Shell 默认提供以下内置命令,这些命令在应用启动后可直接使用,无需额外配置。各命令功能及典型用法如下

命令名称功能描述典型用法
help查看所有可用命令及其描述(输入 help <命令>可查看特定命令详情)shell:> help``shell:> help add
clear清空控制台输出(支持快捷键 Ctrl + Lshell:> clear
exit/ quit退出 Shell 应用(二者功能相同)shell:> exit
script从文件批量执行命令(需提供绝对路径)shell:> script /tmp/commands.txt
stacktrace显示最近一次异常的完整堆栈信息(默认只显示简略错误)发生异常后输入:`shell:> stacktrace``
history显示历史命令shell:> history