Java中如何设置与改变运行时的环境变量

Java 提供了一种与环境变量交互的简单方法。我们可以访问它们,但不能轻易更改它们。然而,在某些情况下,我们需要对环境变量有更多的控制,特别是对于测试场景。

在本教程中,我们将学习如何解决此问题并以编程方式设置或更改环境变量。我们将仅讨论在测试环境中使用它。不鼓励对域逻辑使用动态环境变量,因为它很容易出现问题。

访问环境变量
访问环境变量的过程非常简单。 System类为我们提供了这样的功能:

@Test
void givenOS_whenGetPath_thenVariableIsPresent() {
    String classPath = System.getenv("PATH");
    assertThat(classPath).isNotNull();
}

另外,如果我们需要访问所有变量,我们可以这样做:

@Test
void givenOS_whenGetEnv_thenVariablesArePresent() {
    Map<String, String> environment = System.getenv();
    assertThat(environment).isNotNull();
}

但是,系统不会公开任何设置器,并且我们收到的Map是不可修改的。

更改环境变量
我们可能会在不同的情况下想要更改或设置环境变量。由于我们的流程涉及层次结构,因此我们有三个选择:

  1. 子进程更改/设置父进程的环境变量
  2. 进程更改/设置其环境变量
  3. 父进程更改/设置子进程的环境变量

我们只讨论最后两种情况。第一个更复杂,不能轻易地出于测试目的而合理化。而且,它通常无法用纯 Java 实现,并且通常涉及一些 C/C++ 中的高级编码。

我们将只关注这个问题的 Java 解决方案。 JNI虽然是Java的一部分,但是涉及的比较多,解决方案应该用C/C++来实现。此外,该解决方案可能存在可移植性问题。这就是为什么我们不会详细研究这些方法。

当前流程
在这里,我们有几种选择。其中一些可能被视为黑客,因为不能保证它们适用于所有平台。

1.使用反射 API
从技术上讲,我们可以更改System类,以确保它将使用Reflection API为我们提供所需的值:

@SuppressWarnings("unchecked")
private static Map<String, String> getModifiableEnvironment()
  throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    Class<?> environmentClass = Class.forName(PROCESS_ENVIRONMENT);
    Field environmentField = environmentClass.getDeclaredField(ENVIRONMENT);
    assertThat(environmentField).isNotNull();
    environmentField.setAccessible(true);
    Object unmodifiableEnvironmentMap = environmentField.get(STATIC_METHOD);
    assertThat(unmodifiableEnvironmentMap).isNotNull();
    assertThat(unmodifiableEnvironmentMap).isInstanceOf(UMODIFIABLE_MAP_CLASS);
    Field underlyingMapField = unmodifiableEnvironmentMap.getClass().getDeclaredField(SOURCE_MAP);
    underlyingMapField.setAccessible(true);
    Object underlyingMap = underlyingMapField.get(unmodifiableEnvironmentMap);
    assertThat(underlyingMap).isNotNull();
    assertThat(underlyingMap).isInstanceOf(MAP_CLASS);
    return (Map<String, String>) underlyingMap;
}

然而,这种方法会打破模块的界限。因此,在 Java 9 及更高版本上,可能会导致警告,但代码会编译。而在 Java 16 及更高版本中,它会抛出错误:

java.lang.reflect.InaccessibleObjectException: 
Unable to make field private static final java.util.Map java.lang.ProcessEnvironment.theUnmodifiableEnvironment accessible: 
module java.base does not "opens java.lang" to unnamed module @2c9f9fb0

为了克服后一个问题,我们需要打开系统模块进行反射访问。我们可以使用以下虚拟机选项:

--add-opens java.base/java.util=ALL-UNNAMED 
--add-opens java.base/java.lang=ALL-UNNAMED

从模块运行此代码时,我们可以使用其名称而不是ALL-UNNAMED。

但是,  getenv(String)实现可能因平台而异。此外,我们对内部类的 API 没有任何保证,因此该解决方案可能不适用于所有设置。

为了节省一些输入,我们可以使用JUnit Pioneer 库中已经实现的解决方案:

<dependency>
    <groupId>org.junit-pioneer</groupId>
    <artifactId>junit-pioneer</artifactId>
    <version>2.2.0</version>
    <scope>test</scope>
</dependency>

它使用类似的想法,但提供了更具声明性的方法:

@Test
@SetEnvironmentVariable(key = ENV_VARIABLE_NAME, value = ENV_VARIABLE_VALUE)
void givenVariableSet_whenGetEnvironmentVariable_thenReturnsCorrectValue() {
    String actual = System.getenv(ENV_VARIABLE_NAME);
    assertThat(actual).isEqualTo(ENB_VARIABLE_VALUE);
}

@SetEnvironmentVariable帮助我们定义环境变量。但是,由于它使用反射,我们必须像以前一样提供对封闭模块的访问。

2. JNI
另一种方法是使用JNI并使用 C/C++ 实现设置环境变量的代码。这是一种更具侵入性的方法,并且需要最少的 C/C++ 技能。同时,它也不存在反射访问的问题。

但是,我们不能保证它会更新Java运行时中的变量。我们的应用程序可以在启动时缓存变量,任何进一步的更改都不会产生任何影响。使用反射更改底层Map时不会遇到此问题,因为它仅更改 Java 端的值。

此外,这种方法需要针对不同平台的定制解决方案。由于所有操作系统处理环境变量的方式都不同,因此该解决方案不会像纯 Java 实现那样跨平台。

子进程
ProcessBuilder可以帮助我们直接从Java创建子进程。可以用它运行任何进程。但是,我们将使用它来运行JUnit测试:

@Test
void givenChildProcessTestRunner_whenRunTheTest_thenAllSucceed()
  throws IOException, InterruptedException {
    ProcessBuilder processBuilder = new ProcessBuilder();
    processBuilder.inheritIO();
    Map<String, String> environment = processBuilder.environment();
    environment.put(CHILD_PROCESS_CONDITION, CHILD_PROCESS_VALUE);
    environment.put(ENVIRONMENT_VARIABLE_NAME, ENVIRONMENT_VARIABLE_VALUE);
    Process process = processBuilder.command(arguments).start();
    int errorCode = process.waitFor();
    assertThat(errorCode).isZero();
}

ProcessBuilder提供 API 来访问环境变量并启动单独的进程。我们甚至可以运行Maven测试目标并确定我们要执行哪些测试:

public static final String CHILD_PROCESS_TAG = "child_process";
public static final String TAG = String.format(
"-Dgroups=%s", CHILD_PROCESS_TAG);
private final String testClass = String.format(
"-Dtest=%s", getClass().getName());
private final String[] arguments = {
"mvn", "test", TAG, testClass};

此过程使用特定标签选取同一类中的测试:

@Test
@EnabledIfEnvironmentVariable(named = CHILD_PROCESS_CONDITION, matches = CHILD_PROCESS_VALUE)
@Tag(CHILD_PROCESS_TAG)
void givenChildProcess_whenGetEnvironmentVariable_thenReturnsCorrectValue() {
    String actual = System.getenv(ENVIRONMENT_VARIABLE_NAME);
    assertThat(actual).isEqualTo(ENVIRONMENT_VARIABLE_VALUE);
}

可以定制该解决方案并根据特定要求进行定制。

Docker环境
但是,如果我们需要更多配置或更具体的环境,最好使用Docker和Testcontainers。它将为我们提供更多控制,尤其是集成测试。我们先概述一下 Dockerfile:

FROM maven:3.9-amazoncorretto-17
WORKDIR /app
COPY /src/test/java/com/baeldung/setenvironment/SettingDockerEnvironmentVariableUnitTest.java \
 ./src/test/java/com/baeldung/setenvironment/
COPY /docker-pom.xml ./
ENV CUSTOM_DOCKER_ENV_VARIABLE=TRUE
ENTRYPOINT mvn -f docker-pom.xml test

我们将复制所需的测试并在容器内运行它。此外,我们在同一文件中提供环境变量。

我们可以使用 CI/CD 设置来选取测试中的容器或测试容器来运行测试。虽然它不是最优雅的解决方案,但它可能帮助我们只需单击一下即可运行所有测试。让我们考虑一个简单的例子:

class SettingTestcontainerVariableUnitTest {
    public static final String CONTAINER_REPORT_FILE = "/app/target/surefire-reports/TEST-com.baeldung.setenvironment.SettingDockerEnvironmentVariableUnitTest.xml";
    public static final String HOST_REPORT_FILE =
"./container-test-report.xml";
    public static final String DOCKERFILE =
"./Dockerfile";
    @Test
    void givenTestcontainerEnvironment_whenGetEnvironmentVariable_thenReturnsCorrectValue() {
        Path dockerfilePath = Paths.get(DOCKERFILE);
        GenericContainer container = new GenericContainer(
          new ImageFromDockerfile().withDockerfile(dockerfilePath));
        assertThat(container).isNotNull();
        container.start();
        while (container.isRunning()) {
           
// Busy spin
        }
        container.copyFileFromContainer(CONTAINER_REPORT_FILE, HOST_REPORT_FILE);
    }
}

但是,容器不提供方便的 API 来复制文件夹以获取所有报告。最简单的方法是使用withFileSystemBind()方法,但它已被弃用。另一种方法是直接在 Dockerfile 中创建绑定。

我们可以使用ProcessBuillder重写该示例。主要思想是将 Docker 和常用测试绑定到同一个套件中。 

结论
Java 允许我们直接使用环境变量。然而,改变他们的价值观或设定新的价值观并不容易。

如果我们在领域逻辑中需要这个,则表明我们在大多数情况下违反了几项SOLID原则。然而,在测试过程中,对环境变量的更多控制可能会简化过程并允许我们检查更具体的情况。

虽然我们可以使用反射,但使用 Docker 旋转一个新进程或构建一个全新的环境是更合适的解决方案。