Todos os artigos

// Knowledge.log — 技術記事

@ConditionalOnProperty sem matchIfMissing: o bean que some quando a chave não existe

Boot 4.1.1 e JDK 25: com matchIfMissing=false e a chave ausente, a app sobe e o bean some. ApplicationContextRunner prova e mostra as duas saídas estáveis.

A feature era para estar ligada. O bean tem @ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true") e ninguém colocou app.feature.enabled no YAML do ambiente. A aplicação sobe sem nenhum erro no log e o health fica verde. Mesmo assim o FeatureService não existe no contexto, e o problema só aparece depois, longe da causa, na primeira injeção ou chamada que esperava esse bean.

Uma aplicação que sobe verde sem o bean principal da feature é o tipo de sucesso que ninguém comemora duas vezes.

A ideia aqui é transformar esse comportamento em teste: um ApplicationContextRunner que mostra quando o bean existe, quando não existe e quais são as duas formas estáveis de garantir que ele apareça. Não tem a ver com os casos vizinhos. Na validação aninhada de ConfigurationProperties, o startup pode falhar. No Duration com unidade errada no YAML, o contexto sobe com o valor errado. Aqui o contexto sobe normalmente e o bean simplesmente não está lá.

Ambiente e o que dá para reproduzir

  • Spring Boot 4.1.1, a versão estável atual da linha 4.1.
  • Temurin 25.0.4 e Maven 3.9.12. O Boot 4.1.1 exige no mínimo Java 17 e é compatível até o Java 26, conforme os requisitos de sistema.
  • Dependências: só spring-boot-starter e spring-boot-starter-test. Não tem web starter nem servidor HTTP. O AssertJ vem do starter de teste, então não declare assertj-core de novo.

No projeto de evidência, mvn -q test terminou com 9 testes, 0 falhas, 0 erros e 0 ignorados: 7 em FeatureConfigTests e 2 em FeatureYamlSpringApplicationTests.

O bean condicional

A configuração tem um único bean, ligado por opt-in:

@Configuration(proxyBeanMethods = false)
public class FeatureConfig {

    @Bean
    @ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true")
    FeatureService featureService() {
        return new FeatureService();
    }

}

Vale prestar atenção no atributo que não aparece aí. Segundo o javadoc de ConditionalOnProperty do 4.1.1, matchIfMissing tem default false: "By default missing attributes do not match." Se a chave não existe, a condição falha e o bean não é registrado. O contexto não considera isso um erro, porque o @Conditional foi feito justamente para pular beans.

A matriz com ApplicationContextRunner

O ApplicationContextRunner sobe um contexto não web só com a configuração que você passar. Num mesmo runner dá para variar as propriedades e verificar o resultado:

class FeatureConfigTests {

    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withUserConfiguration(FeatureConfig.class);

    @Test
    void absentKeyDoesNotRegisterBeanAndContextStillStarts() {
        this.runner.run((context) -> {
            assertThat(context).hasNotFailed();
            assertThat(context).doesNotHaveBean(FeatureService.class);
        });
    }

    @Test
    void havingValueTrueRegistersSingleBean() {
        this.runner.withPropertyValues("app.feature.enabled=true").run((context) -> {
            assertThat(context).hasNotFailed();
            assertThat(context).hasSingleBean(FeatureService.class);
        });
    }

}

O assertThat é o org.assertj.core.api.Assertions.assertThat(context). Os métodos hasSingleBean, doesNotHaveBean e hasNotFailed são do ApplicationContextAssert. Não use o context.assertThat(), que está depreciado. O par hasNotFailed + doesNotHaveBean é o que importa aqui: ele separa "o contexto quebrou" de "o contexto subiu e o bean não foi registrado", e esse segundo caso é exatamente o sintoma em produção.

Os seis cenários executados no runner deram este resultado:

ConfiguraçãoBean FeatureServiceContexto
chave ausente, matchIfMissing default (false), havingValue="true"ausente (doesNotHaveBean)subiu (hasNotFailed)
app.feature.enabled=truehasSingleBeansubiu
app.feature.enabled=falseausentesubiu
matchIfMissing=true, chave ausentehasSingleBeansubiu
app.feature.enabled= (string vazia)ausentesubiu
app.feature.enabled=TRUEhasSingleBeansubiu

Nenhuma linha derrubou o contexto. Nas quatro em que o bean não aparece, a aplicação subiria normalmente.

Por que a chave ausente não casa

O código do OnPropertyCondition no 4.1.1 é curto. A chave completa é prefix + name. Se o Environment contém a chave, o valor é comparado com havingValue. Se não contém, o resultado depende só de matchIfMissing: com false, não casa, e com true, casa.

A comparação é que pega desprevenido:

  • Se havingValue não está vazio, a regra é havingValue.equalsIgnoreCase(valor).
  • Se havingValue está vazio, a regra é "o valor não é false".

Por isso TRUE casa com "true" e a string vazia não casa. O havingValue parece booleano, mas é uma comparação de strings que ignora maiúsculas e minúsculas. TRUE passa, e quem configurar yes vai procurar o bean por um bom tempo.

Com YAML de verdade, sem servidor

O runner usa withPropertyValues, que coloca a mesma chave no Environment que o YAML colocaria. Para conferir com um arquivo de verdade, o projeto sobe um SpringApplication sem web usando src/test/resources/feature-on.yml:

app:
  feature:
    enabled: true
@Test
void yamlTrueRegistersBeanWithoutWebServer() {
    AssertableApplicationContext context = AssertableApplicationContext.get(() -> {
        SpringApplication application = new SpringApplication(FeatureConfig.class);
        application.setWebApplicationType(WebApplicationType.NONE);
        return application.run("--spring.config.location=classpath:feature-on.yml");
    });
    try {
        assertThat(context).hasNotFailed();
        assertThat(context).hasSingleBean(FeatureService.class);
    }
    finally {
        context.close();
    }
}

Com o YAML, hasSingleBean. O teste irmão aponta para optional:classpath:does-not-exist.yml, o mesmo cenário de um ambiente em que ninguém configurou a chave, e o resultado é contexto de pé e doesNotHaveBean. É a primeira linha da tabela, agora vinda de um arquivo de configuração.

As duas saídas estáveis

Para garantir o bean, só existem dois caminhos.

1. Ligado por padrão: matchIfMissing = true.

@ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true", matchIfMissing = true)

Sem a chave, o bean é registrado. Quem quiser desligar escreve false.

2. Opt-in explícito: a chave presente no YAML. app.feature.enabled: true no arquivo do ambiente. A anotação continua opt-in, e a responsabilidade passa para a configuração de cada ambiente.

A recomendação da DevDojo depende do comportamento esperado do produto. Se a feature deve estar ligada quando ninguém configurou nada, use matchIfMissing = true e deixe o default no código, perto do bean. Se ela é opt-in de verdade, mantenha a anotação como está e escreva o teste que espera o bean com a configuração real do ambiente. Esse teste falha quando a chave sumir, e é melhor descobrir isso no build do que em produção. O que não funciona é tratar a feature como ligada por padrão e deixar a anotação como opt-in.

Armadilhas

  • havingValue é string. "true" e "TRUE" casam (testados). Pela regra equalsIgnoreCase, valores como yes, 1 ou on não casam com "true". Esses três não estão na matriz executada, mas a regra é a mesma.
  • O default de havingValue é "", não "true". Sem havingValue, a condição passa a ser "chave presente e diferente de false", e um valor qualquer como foo casa. Se o que você quer é semântica booleana, existe o ConditionalOnBooleanProperty desde o 3.5, com havingValue booleano e default true. O matchIfMissing dele também é false.
  • matchIfMissing = true não vence um false explícito. Se a chave existe, o valor é comparado normalmente. app.feature.enabled=false desliga o bean mesmo com matchIfMissing = true.
  • prefix + name. prefix = "app.feature" com name = "enabled" vira app.feature.enabled, e a implementação acrescenta o ponto. Não coloque ponto nos dois lados.
  • Vários names são AND. Todas as chaves precisam passar. Basta uma ausente (com matchIfMissing = false) para o bean não ser registrado.

Próximo passo

Escolha uma das saídas para cada flag: matchIfMissing = true se o padrão do produto é ligado, ou a chave obrigatória no YAML se a feature é opt-in. Depois coloque no módulo dono da flag os dois asserts no mesmo runner: hasSingleBean com a configuração que liga a feature e doesNotHaveBean com a chave ausente, os dois acompanhados de hasNotFailed. São poucas linhas e cobrem o caso em que a aplicação sobe verde sem o bean.

javaspring-boot

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos