Files
kenaito-kopsc/internal/executor/javarun.go
T
odboy f62c1f4872 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 补充导航与示例
2026-11-01 09:16:19 +08:00

396 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}