feat: 新增 java run,按 JDK 版本自动套用调优参数运行 Java 应用

- 自动识别 JDK 8/11/17/21 与宿主机 CPU、可用内存;容器内以 cgroup
  限额为准;堆默认取可用内存 50%,-Xms 与 -Xmx 同值
- 按版本分支生成参数:JDK 8 用 -Xloggc 一组,JDK 9+ 用 -Xlog:;
  堆超过 30g 时自动省略压缩指针参数
- 支持 --jdk/--java/--heap/--xms/--env/--gc-log/--heap-dump-on-oom/
  --print-flags/--dry-run;jar 之后的参数原样交给应用
- 前台运行:stdio 直连、退出码透传、SIGINT/SIGTERM 转发给 JVM
- BuiltinRequest 增加 Stdin,使内置命令可以读取调用方输入
- 修正帮助中的用法行重复(自定义 Usage 不再追加通用透传尾巴)
- 文档:新增 README.java.md,主 README 补充导航与示例
This commit is contained in:
2026-11-01 09:16:19 +08:00
parent 14817bcfe7
commit f62c1f4872
16 changed files with 2139 additions and 7 deletions
+395
View File
@@ -0,0 +1,395 @@
package executor
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
)
// javaRunOptions are the parsed arguments of `kopsc java run`.
type javaRunOptions struct {
jar string // the application jar
appArgs []string // everything after "--", passed to the application
jdk string // --jdk: 8 / 11 / 17 / 21
javaBin string // --java: an explicit java executable
heap string // --heap / --xmx
initial string // --xms
env string // --env
gcDir string // --gc-log directory
heapDump bool // --heap-dump-on-oom
printFlags bool // --print-flags
quiet bool // -q / --quiet
}
// supportedJDKs are the releases kopsc has a tuning profile for.
var supportedJDKs = []string{"8", "11", "17", "21"}
// runJavaRun launches a Spring-style Java application with kopsc's tuned JVM
// flags, in the foreground, so its logs and Ctrl+C behave like a plain
// `java -jar`.
func runJavaRun(ctx context.Context, req BuiltinRequest) error {
opts, err := parseJavaRunArgs(req.Args)
if err != nil {
return err
}
jar, err := resolveJarPath(opts.jar)
if err != nil {
return err
}
jdk, javaBin, err := resolveJDK(opts)
if err != nil {
return err
}
res := platform.DetectResources()
tuning := javaTuning{
JDK: jdk,
CPUs: res.CPUs,
Memory: res.Memory,
Heap: opts.heap,
Initial: opts.initial,
Env: opts.env,
GCDir: opts.gcDir,
HeapDump: opts.heapDump,
}
flags := BuildJavaFlags(tuning)
argv := append([]string{javaBin}, flags...)
argv = append(argv, "-jar", jar)
argv = append(argv, opts.appArgs...)
if opts.printFlags {
fmt.Fprintln(req.Stdout, strings.Join(argv, " "))
return nil
}
if !opts.quiet {
fmt.Fprintf(req.Stdout, "JDK: %s(探测到 %s)\n", jdk, describeJava(javaBin))
fmt.Fprintf(req.Stdout, "资源: %d 核, 内存 %s(%s)\n", res.CPUs, formatBytes(res.Memory), res.Detected)
fmt.Fprintf(req.Stdout, "堆: %s\n", heapSummary(flags))
if opts.gcDir != "" {
fmt.Fprintf(req.Stdout, "GC 日志: %s\n", gcLogPath(opts.gcDir))
}
fmt.Fprintf(req.Stdout, "启动: %s\n\n", strings.Join(argv, " "))
}
if opts.gcDir != "" {
if err := os.MkdirAll(opts.gcDir, 0o755); err != nil {
return fmt.Errorf("创建 GC 日志目录 %q 失败: %w", opts.gcDir, err)
}
}
return runJavaForeground(ctx, argv, req)
}
// runJavaForeground runs the JVM attached to the caller's terminal. The exit
// code is passed through so supervisors see the application's own status.
//
// Ctrl+C is forwarded to the JVM instead of killing kopsc outright, so the
// application's shutdown hooks run and Spring can close its context cleanly.
func runJavaForeground(ctx context.Context, argv []string, req BuiltinRequest) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = req.Stdin
cmd.Stdout = req.Stdout
cmd.Stderr = req.Stderr
// Relay Ctrl+C/SIGTERM to the JVM so its shutdown hooks run.
stopSignals := forwardSignals(cmd)
defer stopSignals()
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动 java 失败: %w", err)
}
// Killing on context cancellation keeps kopsc from outliving its parent
// while the application itself is still shutting down gracefully.
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = cmd.Process.Kill()
case <-done:
}
}()
err := cmd.Wait()
close(done)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return &ExitError{Code: exitErr.ExitCode(), Err: err}
}
return fmt.Errorf("等待 java 退出失败: %w", err)
}
return nil
}
// resolveJarPath checks that the jar exists and returns an absolute path so the
// JVM resolves it independently of its working directory.
func resolveJarPath(jar string) (string, error) {
info, err := os.Stat(jar)
if err != nil {
return "", fmt.Errorf("找不到应用 jar %q: %w;用法: kopsc java run <app.jar> [-- 应用参数...]", jar, err)
}
if info.IsDir() {
return "", fmt.Errorf("%q 是目录,不是 jar 文件", jar)
}
abs, err := filepath.Abs(jar)
if err != nil {
return jar, nil
}
return abs, nil
}
// resolveJDK decides which JDK to tune for and which java executable to run.
func resolveJDK(opts javaRunOptions) (version, javaBin string, err error) {
if opts.jdk != "" {
version = normalizeJDKFlag(opts.jdk)
if !containsString(supportedJDKs, version) {
return "", "", fmt.Errorf("--jdk 只支持 %s,收到 %q",
strings.Join(supportedJDKs, "/"), opts.jdk)
}
return version, javaExecutable(opts.javaBin), nil
}
var info platform.Java
if opts.javaBin != "" {
info = platform.DetectJavaAt(opts.javaBin)
} else {
info = platform.DetectJava()
}
if info.Version == "" {
return "", "", fmt.Errorf("无法自动识别 JDK 版本(%s);请用 --jdk 指定:%s",
describeJavaDetection(info), strings.Join(supportedJDKs, " / "))
}
if !containsString(supportedJDKs, info.Version) {
return "", "", fmt.Errorf("检测到 JDK %s,kopsc 暂无该版本的调优方案;"+
"可用 --jdk 指定按 %s 的参数运行", info.Raw, strings.Join(supportedJDKs, " / "))
}
return info.Version, firstNonEmptyString(info.Path, javaExecutable(opts.javaBin)), nil
}
// javaExecutable returns the java binary to run: --java, or "java" on PATH.
func javaExecutable(explicit string) string {
if explicit != "" {
return explicit
}
return "java"
}
// normalizeJDKFlag accepts "8", "1.8", "jdk8", "JDK 17" and similar spellings.
func normalizeJDKFlag(value string) string {
v := strings.ToLower(strings.TrimSpace(value))
v = strings.TrimPrefix(v, "jdk")
v = strings.TrimSpace(v)
if strings.HasPrefix(v, "1.") {
v = strings.TrimPrefix(v, "1.")
}
if i := strings.IndexAny(v, "._"); i > 0 {
v = v[:i]
}
if _, err := strconv.Atoi(v); err != nil {
return value
}
return v
}
// describeJava renders the java executable for the startup summary.
func describeJava(path string) string {
if path == "" {
return "java(PATH 中)"
}
return path
}
func describeJavaDetection(info platform.Java) string {
if info.Path == "" {
return "PATH 中没有 java"
}
return "java 存在但版本无法解析"
}
// heapSummary extracts the -Xmx value for the startup summary.
func heapSummary(flags []string) string {
for _, f := range flags {
if strings.HasPrefix(f, "-Xmx") {
return strings.TrimPrefix(f, "-Xmx") + "(-Xms 相同或由 --xms 指定)"
}
}
return "交由 JVM 自行决定(未识别到内存)"
}
// formatBytes renders a byte count for humans; "未知" when it is 0.
func formatBytes(n int64) string {
if n <= 0 {
return "未知"
}
return humanSize(n)
}
func containsString(values []string, want string) bool {
for _, v := range values {
if v == want {
return true
}
}
return false
}
func firstNonEmptyString(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
// parseJavaRunArgs parses kopsc's options, which must come before the first
// positional argument (the jar). Everything after "--" is handed to the
// application untouched.
func parseJavaRunArgs(args []string) (javaRunOptions, error) {
var opts javaRunOptions
i := 0
for ; i < len(args); i++ {
arg := args[i]
if arg == "--" {
// `-- jar args` : the jar follows the separator.
i++
break
}
if !strings.HasPrefix(arg, "-") || arg == "-" {
break
}
name, value := arg, ""
if eq := strings.IndexByte(arg, '='); eq >= 0 && strings.HasPrefix(arg, "--") {
name, value = arg[:eq], arg[eq+1:]
}
takeValue := func() (string, error) {
if value != "" {
return value, nil
}
if i+1 >= len(args) {
return "", fmt.Errorf("%s 缺少参数", name)
}
i++
return args[i], nil
}
switch name {
case "--jdk":
v, err := takeValue()
if err != nil {
return opts, err
}
opts.jdk = v
case "--java":
v, err := takeValue()
if err != nil {
return opts, err
}
opts.javaBin = v
case "--heap", "--xmx":
v, err := takeValue()
if err != nil {
return opts, err
}
if parseJVMSize(v) <= 0 {
return opts, fmt.Errorf("--heap 无法识别大小 %q,可用 2g、512m 或字节数", v)
}
opts.heap = v
case "--xms":
v, err := takeValue()
if err != nil {
return opts, err
}
if parseJVMSize(v) <= 0 {
return opts, fmt.Errorf("--xms 无法识别大小 %q,可用 2g、512m 或字节数", v)
}
opts.initial = v
case "--env":
v, err := takeValue()
if err != nil {
return opts, err
}
opts.env = v
case "--gc-log":
v, err := takeValue()
if err != nil {
return opts, err
}
opts.gcDir = v
case "--no-gc-log":
opts.gcDir = "" // explicit, and the default is off
case "--heap-dump-on-oom":
opts.heapDump = true
case "--print-flags":
opts.printFlags = true
case "-q", "--quiet":
opts.quiet = true
default:
return opts, fmt.Errorf("未知选项 %q;应用自己的参数请写在 jar 之后,或用 -- 分隔", name)
}
}
if i >= len(args) {
return opts, errors.New("缺少应用 jar;用法: kopsc java run <app.jar> [-- 应用参数...]")
}
opts.jar = args[i]
rest := args[i+1:]
// A separator after the jar is not an application argument.
if len(rest) > 0 && rest[0] == "--" {
rest = rest[1:]
}
opts.appArgs = append(opts.appArgs, rest...)
return opts, nil
}
// runJavaPreview renders the command `kopsc java run` would execute without
// starting anything. It backs both --dry-run and --print-flags, so the same
// resolution code path is used; only the output differs.
func runJavaPreview(ctx context.Context, req BuiltinRequest) error {
opts, err := parseJavaRunArgs(req.Args)
if err != nil {
return err
}
// The jar may not exist yet in a dry run; report it rather than failing so
// the flags can still be inspected.
if _, statErr := os.Stat(opts.jar); statErr != nil {
fmt.Fprintf(req.Stderr, "注意: %v\n", statErr)
}
jdk, javaBin, err := resolveJDK(opts)
if err != nil {
return err
}
res := platform.DetectResources()
flags := BuildJavaFlags(javaTuning{
JDK: jdk,
CPUs: res.CPUs,
Memory: res.Memory,
Heap: opts.heap,
Initial: opts.initial,
Env: opts.env,
GCDir: opts.gcDir,
HeapDump: opts.heapDump,
})
argv := append([]string{javaBin}, flags...)
argv = append(argv, "-jar", opts.jar)
argv = append(argv, opts.appArgs...)
fmt.Fprintln(req.Stdout, "将要启动的 java 命令:")
fmt.Fprintln(req.Stdout, strings.Join(argv, " "))
fmt.Fprintf(req.Stdout, "\n资源: %d 核, 内存 %s(%s)\n", res.CPUs, formatBytes(res.Memory), res.Detected)
return nil
}