java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java调用外部Go程序

Java调用外部Go程序的实践教学

作者:記億揺晃着的那天

本文基于线上真实代码,介绍了Java如何通过 ProcessBuilder 集成 Go 二进制程序,并分享日志消费、服务就绪检测、生命周期管理等几个容易踩坑的实践经验,希望对大家有所帮助

最近在做一个网络诊断服务。

系统需要动态启动一个 Go 编写的网络组件,完成连接测试和网络状态验证。

由于该组件以独立二进制程序的形式发布,因此需要在 Java 服务中对其进行启动、监控和资源管理。

整个过程看起来并不复杂:

Java
    ↓
生成配置文件
    ↓
启动 Go 程序
    ↓
通过本地端口通信
    ↓
获取结果
    ↓
销毁进程

刚开始我以为重点会是组件本身的能力实现。

后来发现真正花时间的地方其实是:如何在 Java 中可靠地托管一个外部进程。

本文基于线上真实实现,介绍 Java 集成 Go 二进制程序的基本方式,以及几个容易忽略的问题。

为什么选择独立进程

对于 Go 编写的组件,常见集成方式有几种:

我们的场景是:

整个生命周期比较短。

如果单独维护一套常驻服务,反而会增加运维复杂度。

最终采用最简单的方案:

配置文件
+
ProcessBuilder
+
独立进程

优点也比较明显:

启动外部程序

线上实现的核心代码如下:

ProcessBuilder pb = new ProcessBuilder(
        binaryPath,
        "run",
        "-c",
        configFile.getAbsolutePath()
);

pb.directory(
        new File(binaryPath)
                .getParentFile()
);

pb.redirectErrorStream(true);

Process process = pb.start();

这里主要做了几件事:

到这里,Java 已经完成了对 Go 程序的调用。

配置管理

当外部程序配置比较复杂时,不建议大量使用命令行参数。

例如:

--host
--port
--timeout
--tls
--loglevel
...

参数越来越多以后,可维护性会迅速下降。

实际项目中更推荐:

Java
    ↓
生成配置文件
    ↓
启动程序

例如:

Files.writeString(
        configPath,
        configContent
);

然后:

new ProcessBuilder(
        binaryPath,
        "-c",
        configPath.toString()
);

很多成熟项目都采用这种方式。

第一个坑:不消费日志输出

这个问题是上线以后才发现的。

最开始启动完进程就认为结束了:

Process process = pb.start();

结果运行一段时间后,部分任务会莫名超时。

进程存在。

CPU 正常。

内存正常。

但就是没有响应。

排查后发现问题出在输出流。

如果外部程序持续写日志,而 Java 不读取:

stdout buffer 写满
    ↓
写入阻塞
    ↓
进程卡死

解决方案也很简单:

private void consumeStream(
        InputStream inputStream) {

    Thread consumer = new Thread(() -> {

        try (
            BufferedReader reader =
                    new BufferedReader(
                            new InputStreamReader(
                                    inputStream
                            )
                    )
        ) {

            String line;

            while ((line = reader.readLine()) != null) {
                log.debug(line);
            }

        } catch (IOException ignored) {
        }

    });

    consumer.setDaemon(true);
    consumer.start();
}

启动后立即消费:

Process process = pb.start();

consumeStream(
        process.getInputStream()
);

这个问题非常常见,也是最容易被忽略的问题之一。

第二个坑:不要用 sleep 等待启动

很多示例代码会这样写:

process.start();

Thread.sleep(3000);

本地测试通常没问题。

但线上环境经常出现随机失败。

原因很简单:进程启动成功≠服务已经可用

尤其是在:

环境中更明显。

实际项目中采用端口探测方式:

private boolean waitForPortReady(
        int port,
        int retries,
        int intervalMs) {

    for (int i = 0; i < retries; i++) {

        try (Socket socket = new Socket()) {

            socket.connect(
                    new InetSocketAddress(
                            "127.0.0.1",
                            port
                    ),
                    intervalMs
            );

            return true;

        } catch (IOException ignored) {

            try {
                Thread.sleep(intervalMs);
            } catch (InterruptedException e) {
                return false;
            }
        }
    }

    return false;
}

启动完成后主动检测:

if (!waitForPortReady(
        localPort,
        10,
        500
)) {
    throw new RuntimeException(
            "service start timeout"
    );
}

相比固定等待时间,这种方式更加稳定。

第三个坑:生命周期管理

启动进程很简单。

真正麻烦的是退出。

一开始为了省事:

process.destroyForcibly();

虽然能结束进程。

但运行时间长了以后,会发现资源释放并不稳定。

后来改成:

process.destroy();

if (!process.waitFor(
        5,
        TimeUnit.SECONDS
)) {

    process.destroyForcibly();
}

原则很简单:

这样资源释放会稳定很多。

临时文件清理

如果采用配置文件方式启动外部程序。

通常还会产生:/tmp/xxx

或者:temp/xxx

这类临时目录。

推荐统一使用:

try {

    ...

} finally {

    cleanup();

}

保证无论成功还是失败,都能够完成清理。

对于 Spring 项目:

@PreDestroy
public void cleanup() {
    ...
}

再增加一次兜底回收。

最终实现结构

整个系统最终保持了非常简单的结构:

Java
    ↓
生成配置
    ↓
ProcessBuilder
    ↓
Go Binary
    ↓
本地端口通信
    ↓
返回结果

Java 负责:

Go 程序负责:

两者通过配置文件和本地端口进行交互。

职责划分非常清晰。

总结

如果你的目标是在 Java 中复用 Go 程序能力,其实并不需要引入复杂的架构。

大多数场景下:

ProcessBuilder
+
配置文件

已经足够。

真正需要关注的只有三个问题:

把这几个问题处理好之后,无论集成的是网络组件、数据处理工具还是其他 Go 编写的程序,实现方式都基本一致。

以上就是Java调用外部Go程序的实践教学的详细内容,更多关于Java调用外部Go程序的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文