Java Picocli 实战详解:用注解写出好用的命令行工具

简介

Picocli 是 Java 生态里很常用的命令行工具开发库。

它主要解决这些问题:

text 复制代码
解析命令行参数
处理短选项和长选项
支持位置参数
自动做类型转换
生成帮助文档
生成版本信息
支持子命令
支持命令补全
支持打包成可执行 Jar
支持 GraalVM Native Image

一句话概括:

text 复制代码
Picocli 用注解描述命令、选项和参数,把 String[] args 解析成 Java 对象。

没有 Picocli 时,命令行参数通常要手动解析:

java 复制代码
public static void main(String[] args) {
    for (int i = 0; i < args.length; i++) {
        if ("--file".equals(args[i])) {
            String file = args[++i];
            System.out.println(file);
        }
    }
}

这种写法很快会变乱:

  • --file a.txt
  • --file=a.txt
  • -f a.txt
  • -abc
  • 必填参数
  • 默认值
  • 参数类型转换
  • 参数错误提示
  • --help
  • 子命令

Picocli 把这些通用能力封装好了。

Picocli 适合什么场景

常见场景如下:

场景 示例
开发者工具 代码生成、项目初始化、接口调试
运维工具 发布、备份、清理、巡检
数据处理工具 CSV 转换、日志分析、批量导入
管理脚本 用户管理、配置检查、任务触发
Spring Boot CLI 复用 Service 做命令行管理工具
本地小工具 文件重命名、图片处理、目录扫描

它和一些常见 CLI 工具的定位类似:

语言 常见 CLI 框架
Java Picocli
Go Cobra
Python argparse / Click / Typer
Node.js Commander
.NET System.CommandLine

Maven 依赖

当前 picocli 最新稳定版本是 4.7.7

Maven:

xml 复制代码
<dependency>
    <groupId>info.picocli</groupId>
    <artifactId>picocli</artifactId>
    <version>4.7.7</version>
</dependency>

Gradle Kotlin DSL:

kotlin 复制代码
dependencies {
    implementation("info.picocli:picocli:4.7.7")
}

如果要生成 GraalVM Native Image 配置、命令补全脚本等,可以再加 picocli-codegen

xml 复制代码
<dependency>
    <groupId>info.picocli</groupId>
    <artifactId>picocli-codegen</artifactId>
    <version>4.7.7</version>
    <scope>provided</scope>
</dependency>

核心注解

Picocli 最常用的注解不多。

注解 作用
@Command 定义一个命令
@Option 定义选项,比如 -f--file
@Parameters 定义位置参数,比如 copy a.txt b.txt 里的两个文件
@Mixin 复用一组公共选项
@ParentCommand 在子命令里拿到父命令对象
@Spec 拿到当前命令的元数据

一个命令类通常实现 RunnableCallable<Integer>

text 复制代码
Runnable:只执行逻辑,不关心返回值
Callable<Integer>:可以返回退出码,更适合正式 CLI

正式工具更推荐 Callable<Integer>

退出码约定:

text 复制代码
0:成功
非 0:失败

第一个 Demo:hello 命令

先写一个最简单的命令。

目标:

shell 复制代码
java -jar hello.jar 张三

输出:

text 复制代码
Hello, 张三

代码:

java 复制代码
package com.example.picoclidemo;

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Parameters;

import java.util.concurrent.Callable;

@Command(
        name = "hello",
        description = "打印问候语",
        mixinStandardHelpOptions = true,
        version = "hello 1.0.0"
)
public class HelloCommand implements Callable<Integer> {

    @Parameters(index = "0", description = "名称")
    private String name;

    @Override
    public Integer call() {
        System.out.println("Hello, " + name);
        return 0;
    }

    public static void main(String[] args) {
        int exitCode = new CommandLine(new HelloCommand()).execute(args);
        System.exit(exitCode);
    }
}

运行:

shell 复制代码
java com.example.picoclidemo.HelloCommand 张三

输出:

text 复制代码
Hello, 张三

查看帮助:

shell 复制代码
java com.example.picoclidemo.HelloCommand --help

输出类似:

text 复制代码
Usage: hello [-hV] <name>
打印问候语
      <name>      名称
  -h, --help      Show this help message and exit.
  -V, --version   Print version information and exit.

mixinStandardHelpOptions = true 会自动添加:

text 复制代码
-h, --help
-V, --version

这两个选项几乎每个正式 CLI 都应该有。

Option 和 Parameters 的区别

命令行参数大致分两类。

Option

带名字的参数叫选项。

比如:

shell 复制代码
tool --file app.log --limit 100 --verbose

对应 Picocli:

java 复制代码
@Option(names = {"-f", "--file"}, description = "文件路径")
private File file;

@Option(names = {"-l", "--limit"}, defaultValue = "100", description = "最多处理多少行")
private int limit;

@Option(names = {"-v", "--verbose"}, description = "输出详细日志")
private boolean verbose;

Parameters

没有名字、靠位置识别的参数叫位置参数。

比如:

shell 复制代码
copy source.txt target.txt

对应 Picocli:

java 复制代码
@Parameters(index = "0", description = "源文件")
private File source;

@Parameters(index = "1", description = "目标文件")
private File target;

简单判断:

text 复制代码
有 - 或 -- 前缀:@Option
没有前缀,靠顺序:@Parameters

实战 Demo:文件统计工具

下面做一个稍微实用点的工具:统计文本文件。

支持功能:

text 复制代码
统计行数
统计字符数
忽略空行
限制最多读取多少行
输出详细信息

命令示例:

shell 复制代码
textstat README.md --chars --ignore-blank --limit 1000 -v

命令类

java 复制代码
package com.example.picoclidemo;

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.Callable;

@Command(
        name = "textstat",
        description = "统计文本文件的行数和字符数",
        mixinStandardHelpOptions = true,
        version = "textstat 1.0.0"
)
public class TextStatCommand implements Callable<Integer> {

    @Parameters(index = "0", description = "要统计的文本文件")
    private Path file;

    @Option(names = {"-c", "--chars"}, description = "统计字符数")
    private boolean countChars;

    @Option(names = {"--ignore-blank"}, description = "忽略空行")
    private boolean ignoreBlank;

    @Option(names = {"-l", "--limit"}, defaultValue = "0", description = "最多读取多少行,0 表示不限制")
    private int limit;

    @Option(names = {"-v", "--verbose"}, description = "输出详细信息")
    private boolean verbose;

    @Override
    public Integer call() {
        if (!Files.exists(file)) {
            System.err.println("文件不存在: " + file);
            return 2;
        }

        if (!Files.isRegularFile(file)) {
            System.err.println("不是普通文件: " + file);
            return 2;
        }

        try {
            List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);

            if (limit > 0 && lines.size() > limit) {
                lines = lines.subList(0, limit);
            }

            long lineCount = lines.stream()
                    .filter(line -> !ignoreBlank || !line.isBlank())
                    .count();

            System.out.println("lines=" + lineCount);

            if (countChars) {
                int chars = lines.stream()
                        .filter(line -> !ignoreBlank || !line.isBlank())
                        .mapToInt(String::length)
                        .sum();
                System.out.println("chars=" + chars);
            }

            if (verbose) {
                System.out.println("file=" + file.toAbsolutePath());
                System.out.println("limit=" + limit);
                System.out.println("ignoreBlank=" + ignoreBlank);
            }

            return 0;
        } catch (IOException e) {
            System.err.println("读取文件失败: " + e.getMessage());
            return 1;
        }
    }

    public static void main(String[] args) {
        int exitCode = new CommandLine(new TextStatCommand()).execute(args);
        System.exit(exitCode);
    }
}

运行:

shell 复制代码
java com.example.picoclidemo.TextStatCommand README.md

输出:

text 复制代码
lines=128

统计字符数:

shell 复制代码
java com.example.picoclidemo.TextStatCommand README.md --chars

输出:

text 复制代码
lines=128
chars=5042

忽略空行并输出详细信息:

shell 复制代码
java com.example.picoclidemo.TextStatCommand README.md --ignore-blank -v

输出:

text 复制代码
lines=96
file=/Users/test/project/README.md
limit=0
ignoreBlank=true

缺少文件参数:

shell 复制代码
java com.example.picoclidemo.TextStatCommand

Picocli 会直接提示参数缺失,并打印用法。

常用 Option 写法

必填选项

java 复制代码
@Option(names = {"-u", "--username"}, required = true, description = "用户名")
private String username;

没传会报错:

text 复制代码
Missing required option: '--username=<username>'

默认值

java 复制代码
@Option(names = {"-p", "--port"}, defaultValue = "8080", description = "端口,默认 ${DEFAULT-VALUE}")
private int port;

帮助信息里可以用:

text 复制代码
${DEFAULT-VALUE}

显示默认值。

布尔开关

java 复制代码
@Option(names = {"-v", "--verbose"}, description = "详细输出")
private boolean verbose;

只要命令里出现 -v,值就是 true

shell 复制代码
tool -v
tool --verbose

多值参数

java 复制代码
@Option(names = {"-t", "--tag"}, description = "标签,可重复")
private List<String> tags;

运行:

shell 复制代码
tool -t java -t cli -t demo

结果:

text 复制代码
[java, cli, demo]

也可以用 split

java 复制代码
@Option(names = "--tags", split = ",", description = "逗号分隔的标签")
private List<String> tags;

运行:

shell 复制代码
tool --tags java,cli,demo

可选值

有些选项传不传值都可以。

比如:

shell 复制代码
tool --color
tool --color=always
tool --color=never

写法:

java 复制代码
@Option(
        names = "--color",
        arity = "0..1",
        fallbackValue = "always",
        defaultValue = "auto",
        description = "颜色模式: auto, always, never"
)
private String color;

含义:

text 复制代码
不传 --color:auto
只传 --color:always
传 --color=never:never

枚举参数

java 复制代码
enum OutputFormat {
    TEXT, JSON, CSV
}

@Option(names = {"-f", "--format"}, defaultValue = "TEXT", description = "输出格式: ${COMPLETION-CANDIDATES}")
private OutputFormat format;

COMPLETION-CANDIDATES 会显示候选值。

运行:

shell 复制代码
tool --format JSON

位置参数

单个位置参数:

java 复制代码
@Parameters(index = "0", description = "输入文件")
private Path input;

多个位置参数:

java 复制代码
@Parameters(index = "0", description = "源文件")
private Path source;

@Parameters(index = "1", description = "目标文件")
private Path target;

剩余参数:

java 复制代码
@Parameters(index = "0..*", description = "文件列表")
private List<Path> files;

运行:

shell 复制代码
tool a.txt b.txt c.txt

类型转换

Picocli 内置了很多常见类型转换。

常见类型:

Java 类型 命令行传值
String hello
int / Integer 8080
long / Long 10000
boolean true / false,或布尔开关
File ./a.txt
Path ./a.txt
URL https://example.com
URI file:///tmp/a.txt
Duration PT10S
LocalDate 2026-01-01
Enum JSON

如果内置转换不够,可以写自定义转换器。

自定义 LocalDate 转换器

假设日期想支持:

text 复制代码
2026-07-18

转换器:

java 复制代码
package com.example.picoclidemo;

import picocli.CommandLine.ITypeConverter;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class LocalDateConverter implements ITypeConverter<LocalDate> {

    @Override
    public LocalDate convert(String value) {
        return LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE);
    }
}

使用:

java 复制代码
@Option(names = "--date", converter = LocalDateConverter.class, description = "日期,格式 yyyy-MM-dd")
private LocalDate date;

运行:

shell 复制代码
tool --date 2026-07-18

子命令 Demo:filecli

复杂 CLI 一般不是一个命令解决所有问题,而是类似:

shell 复制代码
git add
git commit
kubectl get pods
docker image ls

Picocli 支持子命令。

下面做一个 filecli

text 复制代码
filecli stat README.md
filecli copy a.txt b.txt --force
filecli delete temp.log --dry-run

主命令

java 复制代码
package com.example.picoclidemo.filecli;

import picocli.CommandLine;
import picocli.CommandLine.Command;

@Command(
        name = "filecli",
        description = "文件处理命令行工具",
        mixinStandardHelpOptions = true,
        version = "filecli 1.0.0",
        subcommands = {
                StatCommand.class,
                CopyCommand.class,
                DeleteCommand.class
        }
)
public class FileCliCommand implements Runnable {

    @Override
    public void run() {
        CommandLine.usage(this, System.out);
    }

    public static void main(String[] args) {
        int exitCode = new CommandLine(new FileCliCommand()).execute(args);
        System.exit(exitCode);
    }
}

stat 子命令

java 复制代码
package com.example.picoclidemo.filecli;

import picocli.CommandLine.Command;
import picocli.CommandLine.Parameters;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Callable;

@Command(name = "stat", description = "查看文件基本信息")
public class StatCommand implements Callable<Integer> {

    @Parameters(index = "0", description = "文件路径")
    private Path file;

    @Override
    public Integer call() throws Exception {
        if (!Files.exists(file)) {
            System.err.println("文件不存在: " + file);
            return 2;
        }

        System.out.println("path=" + file.toAbsolutePath());
        System.out.println("size=" + Files.size(file));
        System.out.println("directory=" + Files.isDirectory(file));
        System.out.println("regularFile=" + Files.isRegularFile(file));
        return 0;
    }
}

copy 子命令

java 复制代码
package com.example.picoclidemo.filecli;

import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.concurrent.Callable;

@Command(name = "copy", description = "复制文件")
public class CopyCommand implements Callable<Integer> {

    @Parameters(index = "0", description = "源文件")
    private Path source;

    @Parameters(index = "1", description = "目标文件")
    private Path target;

    @Option(names = {"-f", "--force"}, description = "覆盖已存在文件")
    private boolean force;

    @Override
    public Integer call() throws Exception {
        if (!Files.exists(source)) {
            System.err.println("源文件不存在: " + source);
            return 2;
        }

        if (Files.exists(target) && !force) {
            System.err.println("目标文件已存在,使用 --force 覆盖: " + target);
            return 3;
        }

        if (force) {
            Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
        } else {
            Files.copy(source, target);
        }

        System.out.println("复制完成: " + source + " -> " + target);
        return 0;
    }
}

delete 子命令

java 复制代码
package com.example.picoclidemo.filecli;

import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Callable;

@Command(name = "delete", description = "删除文件")
public class DeleteCommand implements Callable<Integer> {

    @Parameters(index = "0", description = "文件路径")
    private Path file;

    @Option(names = "--dry-run", description = "只打印将要执行的操作,不真正删除")
    private boolean dryRun;

    @Override
    public Integer call() throws Exception {
        if (!Files.exists(file)) {
            System.err.println("文件不存在: " + file);
            return 2;
        }

        if (dryRun) {
            System.out.println("[dry-run] 将删除: " + file.toAbsolutePath());
            return 0;
        }

        Files.delete(file);
        System.out.println("删除完成: " + file.toAbsolutePath());
        return 0;
    }
}

运行:

shell 复制代码
java com.example.picoclidemo.filecli.FileCliCommand stat README.md
java com.example.picoclidemo.filecli.FileCliCommand copy a.txt b.txt --force
java com.example.picoclidemo.filecli.FileCliCommand delete temp.log --dry-run

查看子命令帮助:

shell 复制代码
java com.example.picoclidemo.filecli.FileCliCommand copy --help

输出类似:

text 复制代码
Usage: filecli copy [-f] <source> <target>
复制文件
      <source>   源文件
      <target>   目标文件
  -f, --force    覆盖已存在文件

复用公共选项:Mixin

很多子命令都会有公共参数,比如:

text 复制代码
--verbose
--profile
--config

可以用 @Mixin 复用。

公共选项:

java 复制代码
package com.example.picoclidemo.filecli;

import picocli.CommandLine.Option;

import java.nio.file.Path;

public class CommonOptions {

    @Option(names = {"-v", "--verbose"}, description = "输出详细日志")
    boolean verbose;

    @Option(names = "--config", description = "配置文件路径")
    Path config;
}

子命令使用:

java 复制代码
@Mixin
private CommonOptions commonOptions;

完整示例:

java 复制代码
@Command(name = "stat", description = "查看文件基本信息")
public class StatCommand implements Callable<Integer> {

    @Mixin
    private CommonOptions commonOptions;

    @Parameters(index = "0", description = "文件路径")
    private Path file;

    @Override
    public Integer call() throws Exception {
        if (commonOptions.verbose) {
            System.out.println("config=" + commonOptions.config);
        }
        System.out.println("path=" + file.toAbsolutePath());
        return 0;
    }
}

运行:

shell 复制代码
filecli stat README.md --verbose --config ./app.properties

交互式输入

密码、Token 这类敏感参数不适合直接写在命令里。

可以使用交互式输入:

java 复制代码
@Option(names = "--username", required = true, description = "用户名")
private String username;

@Option(
        names = "--password",
        interactive = true,
        arity = "0..1",
        description = "密码,不在命令行中明文显示"
)
private char[] password;

运行:

shell 复制代码
tool --username admin --password

控制台会提示输入密码。

相比:

shell 复制代码
tool --password 123456

交互式输入更适合敏感信息。

原因是命令行参数可能被历史记录、进程列表、审计日志记录下来。

参数校验

Picocli 本身会做一些基本校验:

  • 必填选项
  • 参数个数
  • 类型转换
  • 枚举值是否合法

比如:

java 复制代码
@Option(names = "--port", required = true)
private int port;

传入:

shell 复制代码
tool --port abc

会提示无法转换成整数。

更复杂的业务校验可以放到 call() 里。

示例:

java 复制代码
@Option(names = "--port", defaultValue = "8080", description = "端口")
private int port;

@Override
public Integer call() {
    if (port < 1 || port > 65535) {
        System.err.println("端口范围必须是 1 到 65535");
        return 2;
    }

    return 0;
}

也可以使用自定义转换器提前拦截非法值。

错误处理和退出码

CommandLine.execute(args) 会返回退出码。

常见做法:

java 复制代码
public static void main(String[] args) {
    int exitCode = new CommandLine(new AppCommand()).execute(args);
    System.exit(exitCode);
}

常见退出码可以这样约定:

退出码 含义
0 成功
1 普通执行失败
2 参数或输入文件问题
3 目标已存在、状态冲突

Picocli 内置常量:

java 复制代码
CommandLine.ExitCode.OK
CommandLine.ExitCode.USAGE
CommandLine.ExitCode.SOFTWARE

示例:

java 复制代码
return CommandLine.ExitCode.OK;

如果需要统一处理异常,可以配置异常处理器:

java 复制代码
CommandLine commandLine = new CommandLine(new FileCliCommand());
commandLine.setExecutionExceptionHandler((ex, cmd, parseResult) -> {
    cmd.getErr().println("执行失败: " + ex.getMessage());
    return 1;
});

int exitCode = commandLine.execute(args);
System.exit(exitCode);

帮助文档写好一点

命令行工具的帮助信息很重要。

推荐给 @Command 写清楚这些字段:

java 复制代码
@Command(
        name = "filecli",
        description = "文件处理命令行工具",
        synopsisHeading = "%n用法:%n  ",
        descriptionHeading = "%n说明:%n",
        optionListHeading = "%n选项:%n",
        parameterListHeading = "%n参数:%n",
        commandListHeading = "%n子命令:%n",
        mixinStandardHelpOptions = true,
        version = "filecli 1.0.0"
)

选项描述里可以使用变量:

java 复制代码
@Option(
        names = "--format",
        defaultValue = "TEXT",
        description = "输出格式: ${COMPLETION-CANDIDATES},默认 ${DEFAULT-VALUE}"
)
private OutputFormat format;

常见变量:

变量 说明
${DEFAULT-VALUE} 默认值
${COMPLETION-CANDIDATES} 候选值
${FALLBACK-VALUE} 可选值参数的 fallback 值

命令补全

Picocli 可以生成命令补全脚本。

补全能力包括:

  • 命令名
  • 子命令
  • 选项
  • 枚举候选值

添加 codegen 依赖后,可以生成脚本。

常见方式是在主命令里加入 AutoComplete.GenerateCompletion 子命令:

java 复制代码
@Command(
        name = "filecli",
        mixinStandardHelpOptions = true,
        subcommands = {
                StatCommand.class,
                CopyCommand.class,
                DeleteCommand.class,
                picocli.AutoComplete.GenerateCompletion.class
        }
)
public class FileCliCommand implements Runnable {
    @Override
    public void run() {
        CommandLine.usage(this, System.out);
    }
}

生成脚本:

shell 复制代码
java -cp "target/filecli.jar" com.example.picoclidemo.filecli.FileCliCommand generate-completion

也可以用 picocli.AutoComplete 工具类按命令类生成。

不同 Shell 的安装方式不同,常见是把生成脚本放到 Bash、Zsh 或 Fish 的补全目录里。

打包成可执行 Jar

命令行工具通常希望这样运行:

shell 复制代码
java -jar filecli.jar stat README.md

可以用 Maven Shade Plugin 打包 fat jar。

xml 复制代码
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.6.0</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>
                    <configuration>
                        <transformers>
                            <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>com.example.picoclidemo.filecli.FileCliCommand</mainClass>
                            </transformer>
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

打包:

shell 复制代码
mvn clean package

运行:

shell 复制代码
java -jar target/filecli-1.0.0.jar stat README.md

为了像普通命令一样运行,可以加一个脚本。

filecli

shell 复制代码
#!/usr/bin/env sh
java -jar /opt/filecli/filecli-1.0.0.jar "$@"

授权:

shell 复制代码
chmod +x filecli

运行:

shell 复制代码
./filecli stat README.md

GraalVM Native Image

CLI 工具很适合打成 Native Image。

好处:

  • 启动快
  • 不需要目标机器安装 JVM
  • 分发更像普通二进制命令

Picocli 对 GraalVM Native Image 支持比较好。

Maven Native Build Tools 示例:

xml 复制代码
<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <version>1.1.2</version>
                    <extensions>true</extensions>
                    <configuration>
                        <mainClass>com.example.picoclidemo.filecli.FileCliCommand</mainClass>
                        <imageName>filecli</imageName>
                    </configuration>
                    <executions>
                        <execution>
                            <id>build-native</id>
                            <goals>
                                <goal>compile-no-fork</goal>
                            </goals>
                            <phase>package</phase>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

构建:

shell 复制代码
mvn -Pnative package

运行:

shell 复制代码
./target/filecli stat README.md

Native Image 构建需要安装 GraalVM 和本地 C 编译工具链。

如果项目引入了反射、动态代理、资源文件,可能还需要额外配置。纯 Picocli 小工具通常比较顺。

Spring Boot 集成

如果 CLI 需要复用 Spring Bean,比如:

  • UserService
  • OrderService
  • Repository
  • 配置文件
  • 数据源

可以使用 picocli-spring-boot-starter

依赖:

xml 复制代码
<dependency>
    <groupId>info.picocli</groupId>
    <artifactId>picocli-spring-boot-starter</artifactId>
    <version>4.7.7</version>
</dependency>

命令类:

java 复制代码
package com.example.bootcli;

import org.springframework.stereotype.Component;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

import java.util.concurrent.Callable;

@Component
@Command(name = "user", description = "用户管理命令", mixinStandardHelpOptions = true)
public class UserCommand implements Callable<Integer> {

    private final UserService userService;

    @Option(names = "--list", description = "列出用户")
    private boolean list;

    public UserCommand(UserService userService) {
        this.userService = userService;
    }

    @Override
    public Integer call() {
        if (list) {
            userService.findAll().forEach(System.out::println);
            return 0;
        }

        System.out.println("没有指定操作");
        return 2;
    }
}

启动类:

java 复制代码
package com.example.bootcli;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import picocli.CommandLine;
import picocli.CommandLine.IFactory;

@SpringBootApplication
public class BootCliApplication implements CommandLineRunner {

    private final UserCommand userCommand;
    private final IFactory factory;

    public BootCliApplication(UserCommand userCommand, IFactory factory) {
        this.userCommand = userCommand;
        this.factory = factory;
    }

    public static void main(String[] args) {
        SpringApplication.run(BootCliApplication.class, args);
    }

    @Override
    public void run(String... args) {
        int exitCode = new CommandLine(userCommand, factory).execute(args);
        System.exit(exitCode);
    }
}

运行:

shell 复制代码
java -jar boot-cli.jar --list

这种方式的关键点是:

text 复制代码
命令类交给 Spring 管理
CommandLine 使用 Spring 提供的 IFactory 创建对象
子命令、转换器等也可以走 Spring 注入

如果只是一个很小的 CLI,不一定需要 Spring Boot。Spring Boot 启动成本更高,适合需要复用完整业务容器的场景。

单元测试

CLI 工具也应该测试。

Picocli 测试很方便,可以直接构造命令对象,捕获输出。

示例命令:

java 复制代码
@Command(name = "hello")
class HelloCommand implements Callable<Integer> {
    @Parameters(index = "0")
    String name;

    PrintWriter out = new PrintWriter(System.out, true);

    @Override
    public Integer call() {
        out.println("Hello, " + name);
        return 0;
    }
}

测试:

java 复制代码
package com.example.picoclidemo;

import org.junit.jupiter.api.Test;
import picocli.CommandLine;

import java.io.PrintWriter;
import java.io.StringWriter;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class HelloCommandTest {

    @Test
    void shouldPrintHello() {
        HelloCommand command = new HelloCommand();
        StringWriter output = new StringWriter();
        command.out = new PrintWriter(output);

        int exitCode = new CommandLine(command).execute("张三");

        assertEquals(0, exitCode);
        assertTrue(output.toString().contains("Hello, 张三"));
    }
}

也可以直接测试错误参数:

java 复制代码
@Test
void shouldReturnUsageExitCodeWhenMissingName() {
    int exitCode = new CommandLine(new HelloCommand()).execute();

    assertEquals(CommandLine.ExitCode.USAGE, exitCode);
}

和 Apache Commons CLI 的区别

Java 里还有一个老牌库叫 Apache Commons CLI

两者大概区别:

对比项 Picocli Apache Commons CLI
开发方式 注解驱动,也支持编程式 API 编程式 API
帮助文档 自动生成能力强 相对基础
子命令 支持较好 需要自行组织
类型转换 内置很多类型 通常手动取字符串再转换
GraalVM 支持较好 不是主要卖点
适合场景 完整 CLI 工具 简单参数解析

新写命令行工具,Picocli 更省事。

如果只是解析几个参数,Commons CLI 也够用。

常见坑

main 方法里没有 System.exit

错误写法:

java 复制代码
new CommandLine(new AppCommand()).execute(args);

脚本调用时无法正确拿到退出码。

推荐:

java 复制代码
int exitCode = new CommandLine(new AppCommand()).execute(args);
System.exit(exitCode);

还在使用 CommandLine.run 或 call

旧示例里经常看到:

java 复制代码
CommandLine.run(new AppCommand(), args);
CommandLine.call(new AppCommand(), args);

这些便捷方法在 Picocli 4.x 里已经不推荐。

推荐:

java 复制代码
new CommandLine(new AppCommand()).execute(args);

布尔参数写成 Boolean 但没有默认值

java 复制代码
@Option(names = "--verbose")
private Boolean verbose;

如果没传,值是 null

大部分布尔开关用基本类型更简单:

java 复制代码
@Option(names = "--verbose")
private boolean verbose;

位置参数顺序不清楚

java 复制代码
@Parameters
private Path source;

@Parameters
private Path target;

多个位置参数建议明确 index

java 复制代码
@Parameters(index = "0")
private Path source;

@Parameters(index = "1")
private Path target;

选项名太随意

不建议写一堆只有短选项的参数:

java 复制代码
-a -b -c -x

正式工具最好短选项和长选项都提供:

java 复制代码
@Option(names = {"-f", "--file"})
@Option(names = {"-o", "--output"})
@Option(names = {"-v", "--verbose"})

长选项更适合脚本,可读性更好。

密码直接从参数传入

不推荐:

shell 复制代码
tool --password 123456

更推荐:

shell 复制代码
tool --password

配合:

java 复制代码
@Option(names = "--password", interactive = true, arity = "0..1")
private char[] password;

总结

Picocli 可以按这条线理解:

text 复制代码
@Command 定义命令
@Option 定义具名选项
@Parameters 定义位置参数
CommandLine.execute 负责解析和执行
Callable<Integer> 返回退出码
subcommands 组织复杂工具
Mixin 复用公共参数

日常开发可以按这个顺序落地:

  • 先写单命令 Demo
  • 再补齐 --help--version
  • 参数尽量用明确类型,比如 PathintEnum
  • 复杂工具拆成子命令
  • 公共选项用 @Mixin
  • 正式发布时打包成可执行 Jar
  • 对启动速度敏感时考虑 Native Image
  • 需要复用业务 Bean 时接入 Spring Boot

Picocli 的价值不是"把参数解析换成注解"这么简单。真正省事的地方在于帮助文档、错误提示、子命令、类型转换、退出码这些细节都已经有一套成熟约定。命令行工具越往后做,越能感受到这些约定带来的稳定性。

相关推荐
X-⃢_⃢-X2 小时前
四、OpenFeign远程调用
java·微服务·springboot·springcloud
减瓦3 小时前
深入 Quarkus:云原生时代 Java 的重生之路
java·开发语言·云原生
杨运交3 小时前
[053][核心模块]Java枚举缓存与ORM集成实践
java·开发语言·缓存
繁星蓝雨3 小时前
C++设计原理——异常处理
java·c++·异常处理·noexcept·throw·try catch
BerrySen1783 小时前
一个Java项目改成AI流程后,最难的部分完全变了
java·大数据·人工智能·可观测性·大模型应用开发·工程思维
程序猿秃头之路4 小时前
DDD 系列:DTO、VO、DO、Entity 怎么区分
java·ddd·领域驱动设计
自由随风飘4 小时前
JAVA中的面向对象-3
java·开发语言
桐薇全肯定5 小时前
MySQL学生成绩管理系统实战操作
java·数据库·sql