弹簧壳 - 构建CLI应用程序
#java #springboot #cli #shell

什么是弹簧壳?

Spring Shell是弹簧生态系统的一个项目,可轻松创建Shell应用程序(通过终端交互的命令行应用程序)。

在本文中,我们将使用一些命令来构建可运行的应用程序,以与客户RESTAPI应用程序进行通信。我们将重新使用上一篇文章(新的Spring RestClient)中的代码。

该项目

我们将构建具有以下功能的CLI应用程序:

  1. 允许按照不同的标准搜索客户并以表格格式显示结果。
  2. 允许通过ID查找特定客户并以表格格式显示信息。
  3. 允许更新客户属性并显示操作的结果。这将需要在应用程序中进行身份验证。
  4. 允许通过ID删除客户并显示操作的结果。这将需要应用程序中的身份验证。

在本文中,我们将集中精力于第1和2项,第二部分将留下3和4。让我们继续下一个部分并开始设置项目。

项目依赖性

这个项目将需要两个依赖性,即弹簧壳和弹簧网。稍后,我们将添加弹簧验证依赖性,以验证用户输入。

maven pom.xml文件必须包含

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

运行应用程序时,Spring Boot检测到类路径中的Shell启动器依赖项,并将自动提示CLI,如下所示

Image description

注册命令

我们需要注册4个命令,每个操作一个。有两种定义命令的方法:使用注释模型并使用程序化模型。我们将为应用程序选择注释模型。方法和类标记了特定的注释来定义命令。

Spring Boot 3.1.x具有新的支持,用于使用注释定义命令。先前的注释(@shell ...)被视为遗产,将被弃用和删除。因此,我们将重点放在新的non子上。

@command注释用于将方法标记为命令注册的候选方法。它也可以将其放在类上,以将设置应用于同一类中定义的方法。

让我们使用命令方法创建一个类

1. @Command(group = "Customer Commands")
2. public class CustomerCommands {
3.     private CustomerService customerService;

4.     public CustomerCommands(CustomerService customerService) {         
5.         this.customerService = customerService;
6.     }

7.     @Command(command="find-customer",
8.              description="Finds a Customer By Id")
9.     public String findCustomer(@Option(required = true, 
10.            description = "The customer id") Long id) {
11.        return customerService.findCustomer(id).toString();
12.     }
}

让我们分析上述代码行:

  1. 在第1行中,该类被标记为命令(这意味着它将具有命令方法)。小组选项组织可以组合在一起并将它们显示在帮助选项中的同一组中的相关命令。
  2. 在第3行中,我们的应用程序将移交给服务以找到客户。
  3. 在第7行中,方法findcustomer被注释为命令方法。注释接受命令选项,以指定从外壳调用时命令的名称。默认情况下,命令键遵循虚线gnu式的名称(例如,方法findcustomer成为find-customer)。另外,将描述设置为提供有关命令的更多信息。
  4. 在第9行中,返回类型将是代表客户的字符串。 @Option将参数标记为“ type Long”的必需命令选项。如果不被告知,将提出错误。可以定义选项的简短和长格式。也就是说,在参数值之前的前缀或 - 。我们将在测试代码时看到它。
  5. 在第11行中,通过ID搜索客户的呼叫已钻探到服务(服务代码将在文章的稍后显示)。

但这还不足以注册目标命令。需要使用@enablecommand和/或@CommandScan注释。

我们将使用@enablecommand注册目标类CustomerCommands。运行应用程序时将捡起它。

@SpringBootApplication
@EnableCommand(CustomerCommands.class)
public class CustomerShellApplication {
    public static void main(String[] args) {
        SpringApplication.run( 
            CustomerShellApplication.class, args);
    }
}

另外,@commandScan将自动从RestClientsHellApplication类中的所有软件包和类中扫描所有命令目标。

@SpringBootApplication
@CommandScan
public class CustomerShellApplication {
    public static void main(String[] args) {
    SpringApplication.run(
            CustomerShellApplication.class, args);
    }
}

现在,该应用程序准备好进行测试。在此之前有一件事要提及。启动应用程序时,Springboot横幅,日志显示在CLI中。这将使用户体验恶化。两者都可以关闭添加属性

logging.level.root=OFF
spring.main.banner-mode=off
spring.main.web-application-type=none

由于该项目包含用于使用RESTCLIENT的Spring MVC依赖项,因此可以禁用Web服务器。

让我们运行应用程序并键入help

Image description

正如我们在上图中看到的那样,有两个命令组。 Spring提供的内置命令。它们是大多数壳中可以找到的常见功能。
第二组是find-customer命令所在的客户命令组。命令和描述显示为注释中的设置。

搜索客户就像键入以下任何一行一样简单

Image description

第一行呼叫未指定ARG名称。第二和第三行分别通过短和长格式。在所有情况下,正确的客户将从Web服务返回。

服务层

我们将在本节中更详细地探索服务层。由于这与Spring Shell不直接相关,因此您可以跳过此部分并转到异常处理部分。

该服务是一个促进命令类和Web服务之间通信的类。

@Service
public class CustomerService {
    private HttpAPIHandler httpAPIHandler;

    public CustomerService(HttpAPIHandler httpAPIHandler) {
        this.httpAPIHandler = httpAPIHandler;
    }

    public CustomerResponse findCustomer(Long id) {
        return httpAPIHandler.findCustomer(id);
    }
}

它使用处理程序使用Spring RestClient调用Web服务。有关RESTCLIENT的工作方式的更多信息,您可以访问上一篇文章here

@Component
public final class HttpAPIHandler {
    private final APIProperties apiProperties;
    private final RestClient restClient;
    private final ObjectMapper objectMapper;

    public HttpAPIHandler(APIProperties apiProperties,
                          ObjectMapper objectMapper) {
        this.properties = properties;
        this.objectMapper = objectMapper;
        restClient = RestClient.builder()
            .baseUrl(properties.getUrl())
            .defaultHeader(HttpHeaders.AUTHORIZATION,
                encodeBasic(properties.getUsername(), 
                            properties.getPassword()))
            .defaultStatusHandler(
                HttpStatusCode::is4xxClientError,
                (request, response) -> {
                    var errorDetails = objectMapper.readValue( 
                        response.getBody().readAllBytes(), 
                        ErrorDetails.class);
                throw new RestClientCustomException( 
                    response.getStatusCode(), errorDetails);
               })
            .build();
    }

    public CustomerResponse findCustomer(Long id) {
        return restClient.get()
            .uri("/{id}",id)
            .accept(MediaType.APPLICATION_JSON)
            .retrieve()
            .body(CustomerResponse.class);
    }
}

注入的第一个构造函数参数是apiproperties。这是一个简单的属性配置类,可从application.properties加载URL,用户名和密码。 @Value适用于单个属性,但我们希望将它们放在一起,因此它们被隔离为分离的Pojo。

@Configuration
@ConfigurationProperties(prefix = "application.rest.v1.customer")
public class ClientProperties {
    String url;
    String username;
    String password;
    // setters/getter omitted
}

和应用程序中的属性定义

application.rest.v1.customer.url=http://localhost:8080/api/v1/customers
application.rest.v1.customer.username=user1234
application.rest.v1.customer.password=password5678

第二个构造函数参数注入是一个对象拍摄者。此类属于Jackson Databind Jar。需要从Web服务到ErrorDetails Record
序列化错误响应(包含状态,消息和时间戳)的错误响应(JSON对象)

public record ErrorDetails(
    int status, String message, LocalDateTime timestamp) {}

在一个分区类中声明对象模型以进行应用程序配置。要正确管理日期和时间,必须注册JavatimeModule。

@Configuration
public class AppConfig {
    @Bean
    ObjectMapper objectMapper(){
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        return objectMapper;
    }
}

连接了服务层的所有部分。

异常处理

在某些情况下,该计划将无法完成任务以成功找到客户。例如,当Web服务降低或不存在客户时会发生什么?下图显示了这些情况下的输出。

Image description

Spring Shell提供了一系列异常解析器实现来解决异常,并且可以返回要在控制台中显示的消息。可以选择地返回出口代码(包裹在CommandHandlingResult中),但这仅适用于非相互作用的外壳。

这里描述了设置异常解析器的步骤:

  1. 创建实现接口CommandexceptionResolver的自定义异常解析器。
  2. 将解析器定义为全球豆(也可以通过编程方式定义特定命令。有关更多信息,请参见Spring Shell文档)。

让我们实现异常解析类别。代码可以在下一行中看到

public class CLIExceptionResolver implements 
    CommandExceptionResolver {
    @Override
    public CommandHandlingResult resolve(Exception ex) {
        if (ex instanceof RestClientCustomException e)
            return CommandHandlingResult.of( 
              e.getErrorDetails().message()+'\n');
        else if (ex instanceof ResourceAccessException e)
            return CommandHandlingResult.of(
              "Customer API is not available at the moment"+'\n');

        return CommandHandlingResult.of(ex.getMessage()+'\n', 1);
    }
}

CommandHandlingResult带有()的超载工厂方法。它采用代表错误消息的字符串,并选择一个退出代码。

接下来必须定义豆。我们将与ObjectMapper一起将其添加到AppConfig类中

@Configuration
public class AppConfig {
    @Bean
    CLIExceptionResolver customExceptionResolver() {
        return new CLIExceptionResolver();
    }
...
}

是时候检查异常处理是否正常。让我们重新进行两个测试。

Image description

现在,两个例外都按照CliexceptionResolver返回消息。

格式输出

在本节中,我们将添加一个新命令来查找客户并将数据显示在表中。

新命令方法接受两个可选参数。代表客户状态和布尔值的枚举,指示客户是否为VIP。

@Command(command = "find-customers",
         description = "Find Customers By Status and vip")
public String findCustomers(
    @Option(required = false,longNames = "status", 
            shortNames = 's') CustomerStatus status,
    @Option(required = false,longNames = "vip", 
            shortNames = 'v') Boolean vip) 
    throws JsonProcessingException {

1.    List<CustomerResponse> customers = 
        customerService.findCustomers(status, vip);
2.    return ouputFormatter.coverToTable(customers);
}

public enum CustomerStatus { ACTIVATED, DEACTIVATED, SUSPENDED } ;

在第1行中,该服务获取按状态和/或VIP过滤的客户列表。 HTTPAPIHANDLER中的代码实现如下所示。代码中最有趣的部分是返回语句,其中响应作为字符串在ObjectMapper的帮助下将其转换为列表。这是因为身体方法不接受参数化类型(通过列表。阶级将返回地图列表)。

public List<CustomerResponse> findCustomers(
    CustomerStatus status, Boolean vip) 
    throws JsonProcessingException {
    String response = restClient.get()
        .uri(getQueryString(status, vip))
        .accept(MediaType.APPLICATION_JSON)
        .retrieve()
        .body(String.class);
    return Arrays.stream(
       objectMapper.readValue(response,CustomerResponse[].class))
       .toList();
}

在第2行中,格式化的OUPUT是在类外形式的帮助下生成的。该类在AppConfig类中被声明为Bean,我们将所有项目保留在其中。然后,它是在CustomerCommands中注入构造函数的。

@Bean
OuputFormatter ouputFormatter(){
    return new OuputFormatter();
}

让我们仔细查看此类以查看表的创建方式。

public final class OuputFormatter {
    public String coverToTable(List<CustomerResponse> customers) {
1.        var data = customers
            .stream()
            .map(OuputFormatter::toRow)
            .collect(Collectors.toList());
2.        data.add(0, 
                 addRow("id", "name", "email", "vip", "status"));

3.        ArrayTableModel model = new ArrayTableModel( 
              data.toArray(Object[][]::new));
4.        TableBuilder table = new TableBuilder(model);
5.        table.addHeaderAndVerticalsBorders( 
              BorderStyle.fancy_light);
6.        return table.build().render(100);
    }

    private static String[] toRow(CustomerResponse c) {
        return addRow(String.valueOf(c.id()),
                c.personInfo().name(),
                c.personInfo().email(),
                String.valueOf(c.detailsInfo().vip()),
                c.status());
    }

    private static String[] addRow(String id, String name, 
        String email, String vip, String status) {
        return new String[] {id, name, email, vip, status};
    }
} 

让我们按行解剖代码:

  1. 客户列表是将客户响应字段的字符串列表转换为字符串列表。
  2. 标题已添加到列表头(列表的第一个元素)。
  3. 来自弹簧壳的ArraytableModel表示由第一个数组备份的表的模型。当数据(外部阵列内的每个阵列都是行)时,Cocustontort会采用二维数组。该列表已转换为数组。
  4. TableBuilder从模型(持有表数据的对象)配置表格。
  5. 边框设置在桌子上。
  6. 表是构建和渲染的,宽度为100。

我们准备使用新命令搜索客户。所需的只是调用新命令并通知任何参数。

Image description

和样式的表格对我们的外壳应用程序很好!

结论

本文比平时更长,但我认为这是值得的。我们已经学习了弹簧壳的基础知识和一些更高级的功能,例如表格格式。

将有第二部分涵盖项目部分的第3和4项。我们还将探索其他高级功能,例如动态可用性。

可以在github repo here中找到proejct的完整代码。

如果您喜欢这篇文章,请随时关注我并每个月收到新的帖子。